From 88caede86a58dce017303eaa0d4d1f8f6824a30a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 9 Jul 2026 09:54:53 -0300 Subject: [PATCH 01/30] fix(work-orders): address PR #14 board PATCH review feedback Reuse field-lock DB checks during staged audits, defer dispatch creation to the final save, and return 404 for missing work orders. --- .../Controllers/WorkOrderController.cs | 8 +++ .../Implementation/WorkOrderAuditService.cs | 42 ++++++---------- .../WorkOrderBoardUpdateService.cs | 27 ++++++---- .../Interfaces/IWorkOrderAuditService.cs | 8 +-- .../WorkOrderBoardUpdateServiceTests.cs | 50 +++++++++++++++++++ 5 files changed, 94 insertions(+), 41 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 530f5d6..21b0caa 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -131,6 +131,14 @@ namespace Api.SeaHavenIndustries.Controllers CurrentState = ex.CurrentState }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 0646cd0..b0e7c60 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -28,17 +28,17 @@ namespace SeaHaven.Services.Implementation => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, AuditActorType.Sync, fieldName, oldValue, newValue); - public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) - => StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); + public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) + => StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); - public void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId) - => StageAudit(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId); + public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) + => StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId); - public void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId) - => StageAudit(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId); + public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId) + => StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId); - public void StageCreated(int workOrderId, string? actorId, string? woNumber) - => StageAudit(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId); + public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber) + => StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId); public async Task LogAsync( int workOrderId, @@ -65,7 +65,7 @@ namespace SeaHaven.Services.Implementation await _context.SaveChangesAsync(); } - private void StageAudit( + private async Task StageAuditAsync( int workOrderId, AuditActionType action, string fieldName, @@ -76,7 +76,13 @@ namespace SeaHaven.Services.Implementation { AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher, fieldName, oldValue, newValue, actorId, dispatchId); - StageFieldLock(workOrderId, fieldName, actorId); + + if (action == AuditActionType.FieldChanged || + action == AuditActionType.StatusChanged || + action == AuditActionType.AssignmentChanged) + { + await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); + } } private void AddAuditEntry( @@ -107,21 +113,5 @@ namespace SeaHaven.Services.Implementation }); } - private void StageFieldLock(int workOrderId, string fieldName, string? userId) - { - var alreadyTracked = _context.WorkOrderFieldLocks.Local - .Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); - - if (alreadyTracked) - return; - - _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock - { - WorkOrderId = workOrderId, - FieldName = fieldName, - LockedAt = DateTime.UtcNow, - LockedByUserId = userId - }); - } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 98239bb..1eb1ce7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -106,16 +106,17 @@ namespace SeaHaven.Services.Implementation foreach (var change in changes) { + var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null); switch (change.Action) { case AuditActionType.StatusChanged: - _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); + await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); break; case AuditActionType.AssignmentChanged: - _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); + await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); break; default: - _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId); + await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId); break; } } @@ -195,8 +196,7 @@ namespace SeaHaven.Services.Implementation CreatedDate = DateTime.UtcNow }; _context.Dispatches.Add(created); - await _context.SaveChangesAsync(); - workOrder.PrimaryDispatchId = created.Id; + workOrder.PrimaryDispatch = created; return created; } @@ -392,10 +392,10 @@ namespace SeaHaven.Services.Implementation var old = dispatch.VendorId.ToString(); if (dispatch.VendorId == vendorId.Value) - return FieldChange.Unchanged(auditField, dispatch.Id); + return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); dispatch.VendorId = vendorId.Value; - return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id); + return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch)); } private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField) @@ -411,12 +411,14 @@ namespace SeaHaven.Services.Implementation var old = FormatDate(dispatch.ScheduledDate); var newVal = FormatDate(parsed); if (old == newVal) - return FieldChange.Unchanged(auditField, dispatch.Id); + return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); dispatch.ScheduledDate = parsed; - return FieldChange.ForField(auditField, old, newVal, dispatch.Id); + return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch)); } + // apptTime is stored on WorkOrder (ScheduledStart/End) but requires a primary dispatch + // for concurrency (dispatchVersion) and audit context; apptDate lives on Dispatch.ScheduledDate. private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField) { if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error)) @@ -435,9 +437,9 @@ namespace SeaHaven.Services.Implementation var newCombined = FormatApptRange(newStart, newEnd); if (oldCombined == newCombined) - return FieldChange.Unchanged(auditField, dispatch.Id); + return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); - return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id); + return FieldChange.ForField(auditField, oldCombined, newCombined, ResolveDispatchIdForAudit(dispatch)); } private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField) @@ -500,6 +502,9 @@ namespace SeaHaven.Services.Implementation return vendorId; } + private static int? ResolveDispatchIdForAudit(Dispatch dispatch) + => dispatch.Id > 0 ? dispatch.Id : null; + private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd"); private static string? FormatDateTime(DateTime? value) => value?.ToString("O"); private static string? FormatApptRange(string? start, string? end) diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index 7029709..3dc4b57 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -20,9 +20,9 @@ namespace SeaHaven.Services.Interfaces Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue); - void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); - void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId); - void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId); - void StageCreated(int workOrderId, string? actorId, string? woNumber); + Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); + Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId); + Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); + Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 59b180c..cb2b73a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -180,6 +180,56 @@ public class WorkOrderBoardUpdateServiceTests Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode")); } + [Fact] + public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + SiteCode = "OLD", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + var updated = await context.workOrders.FindAsync(1); + var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK6", + WorkOrderVersion = ToVersion(updated!) + }, "actor-1"); + + Assert.Equal("BK6", secondResult.SiteCode); + Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync()); + Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync()); + } + + [Fact] + public async Task PatchField_NotFound_ThrowsNotFoundCode() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, "actor-1")); + + Assert.Equal("NotFound", ex.Code); + } + [Fact] public async Task PatchField_NormalizesWoNumber() { From c2d0bd45e37b52df536eef2ff3144a9fdb04b9cc Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 9 Jul 2026 10:06:36 -0300 Subject: [PATCH 02/30] fix(work-orders): address PR #13 review feedback Return BadRequest for invalid board week window, remove duplicate unused board types, fix POC null guard, and document appt precedence and UTC Phase 1 assumptions. --- .../Controllers/WorkOrderController.cs | 6 ++++++ .../Board/WorkOrderBoardConstants.cs | 7 ------- .../DTOs/WorkOrderBoardQuery.cs | 10 ---------- .../DTOs/WorkOrderBoardRowData.cs | 20 ------------------- .../Exceptions/BoardQueryException.cs | 9 --------- .../Helpers/WorkOrderBoardProjection.cs | 2 +- .../Helpers/WorkOrderBoardQueryFilters.cs | 4 ++++ .../Interfaces/PrimaryDispatchProjection.cs | 9 --------- .../Board/WorkOrderBoardConstants.cs | 3 +++ .../Helpers/WorkOrderDerivedFields.cs | 8 ++++++++ .../Helpers/WorkOrderOperationalWeek.cs | 15 ++++++++++++++ .../Implementation/WorkOrderBoardService.cs | 16 +++++---------- 12 files changed, 42 insertions(+), 67 deletions(-) delete mode 100644 SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs delete mode 100644 SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs delete mode 100644 SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs delete mode 100644 SeaHaven.DataServices/Exceptions/BoardQueryException.cs delete mode 100644 SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 71976b7..1989d84 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -17,6 +17,7 @@ using System.Security.Claims; using static System.Runtime.InteropServices.JavaScript.JSType; using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.DataServices.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -89,6 +90,11 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] List? types = null, [FromQuery] string? search = null) { + var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4); + var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd); + if (weekValidationError != null) + return BadRequest(weekValidationError); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var query = new WorkOrderBoardQueryDto { diff --git a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs deleted file mode 100644 index bceff32..0000000 --- a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SeaHaven.DataServices.Board -{ - public static class WorkOrderBoardConstants - { - public const int ClientSideThreshold = 300; - } -} diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs deleted file mode 100644 index 6d5d1e0..0000000 --- a/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SeaHaven.DataServices.DTOs -{ - public record WorkOrderBoardQuery( - DateOnly ScheduledFrom, - DateOnly ScheduledTo, - string? Search, - IReadOnlyList? AssigneeIds, - IReadOnlyList? DbStatuses, - int? LocationId); -} diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs deleted file mode 100644 index a7eca60..0000000 --- a/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace SeaHaven.DataServices.DTOs -{ - public record WorkOrderBoardRowData( - int Id, - string? WoNumber, - string? SiteCode, - string? LocationLabel, - DateTime? ScheduledDate, - DateTime? ScheduledStart, - DateTime? DueDate, - string? WorkOrderType, - string? ServiceType, - string? AssigneeId, - string? AssigneeName, - string? VendorCompany, - string? VendorTechnician, - string? Status, - string? PocName, - string? PocPhone); -} diff --git a/SeaHaven.DataServices/Exceptions/BoardQueryException.cs b/SeaHaven.DataServices/Exceptions/BoardQueryException.cs deleted file mode 100644 index b659424..0000000 --- a/SeaHaven.DataServices/Exceptions/BoardQueryException.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SeaHaven.DataServices.Exceptions -{ - public class BoardQueryException : Exception - { - public BoardQueryException(string message) : base(message) - { - } - } -} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 3b2ebac..75b98df 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -27,7 +27,7 @@ namespace SeaHaven.DataServices.Helpers Name = c.POC != null ? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim() : null, - c.POC!.PhoneNumber, + PhoneNumber = c.POC != null ? c.POC.PhoneNumber : null, c.Notes }) .FirstOrDefault(), diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 35465ca..c57fc5f 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -47,6 +47,8 @@ namespace SeaHaven.DataServices.Helpers return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value)); } + #region Phase 2 filter helpers (not wired in Phase 1 board API) + public static IQueryable ApplySiteFilter( IQueryable query, IReadOnlyList? sites) @@ -135,6 +137,8 @@ namespace SeaHaven.DataServices.Helpers && w.TargetWeek <= dateTo)); } + #endregion + public static IQueryable ApplySearchFilter(IQueryable query, string? search) { if (string.IsNullOrWhiteSpace(search)) diff --git a/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs b/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs deleted file mode 100644 index ce6ebf6..0000000 --- a/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SeaHaven.DataServices.Interfaces -{ - public record PrimaryDispatchProjection( - int WorkOrderId, - int? DispatchId, - string? VendorName, - DateTime? ApptDate, - string? DispatchStatus); -} diff --git a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs index a00f3e2..78d1ab5 100644 --- a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs +++ b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs @@ -1,5 +1,8 @@ namespace SeaHaven.Services.Board { + /// + /// Shared board constants for Phase 2+ features. Not referenced in Phase 1. + /// public static class WorkOrderBoardConstants { public const int MaxWindowDays = 90; diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index cb64549..b64d16c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -20,6 +20,10 @@ namespace SeaHaven.Services.Helpers return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today; } + /// + /// Board read-model past-due check. Phase 1 compares UTC calendar dates; + /// business/location timezone support is planned for a later phase. + /// public static bool IsPastDue( DateTime? scheduledDate, LifecycleStatus? lifecycleStatus, @@ -66,6 +70,10 @@ namespace SeaHaven.Services.Helpers return true; } + /// + /// Maps scheduled date to Mon–Fri board column. Phase 1 uses the stored + /// date's day-of-week (UTC); timezone-aware grouping is planned for a later phase. + /// public static string? GetDayGroup(DateTime? scheduledDate) { if (!scheduledDate.HasValue) diff --git a/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs index 5779a17..e8795f1 100644 --- a/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs +++ b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs @@ -16,5 +16,20 @@ namespace SeaHaven.Services.Helpers public static string BuildWeekCorrelationId(DateOnly sourceWeekStart) => $"week:{sourceWeekStart:yyyy-MM-dd}"; + + /// + /// Returns a validation error message when the week window is invalid; otherwise null. + /// + public static string? ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd) + { + if (weekEnd < weekStart) + return "weekEnd must be on or after weekStart."; + + var spanDays = weekEnd.DayNumber - weekStart.DayNumber; + if (spanDays > 6) + return "Week window cannot exceed 7 days."; + + return null; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index d123f4b..03b5b56 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -22,7 +22,9 @@ namespace SeaHaven.Services.Implementation var weekStart = query.WeekStart; var weekEnd = query.WeekEnd ?? weekStart.AddDays(4); - ValidateWeekWindow(weekStart, weekEnd); + var validationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, weekEnd); + if (validationError != null) + throw new ArgumentException(validationError); var dataQuery = new WorkOrderBoardQuery( weekStart, @@ -62,18 +64,10 @@ namespace SeaHaven.Services.Implementation }).ToList(); } - private static void ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd) - { - if (weekEnd < weekStart) - throw new ArgumentException("weekEnd must be on or after weekStart."); - - var spanDays = weekEnd.DayNumber - weekStart.DayNumber; - if (spanDays > 6) - throw new ArgumentException("Week window cannot exceed 7 days."); - } - private static WorkOrderBoardRowDto MapRow(WorkOrderBoardRawRow row, DateTime utcNow) { + // Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate. + // Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime. var apptStart = row.ScheduledStart ?? row.DispatchApptDate; var apptEnd = row.ScheduledEnd; From 25eb6293e0be52891cfe728a15e73b380b71a24e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 9 Jul 2026 13:24:47 -0300 Subject: [PATCH 03/30] chore: remove docs and scripts from phase 2 PR scope --- docs/adr/work-orders-board-api.md | 194 ---- docs/auditoria-work-orders-api-vs-frontend.md | 299 ------ docs/roadmap-work-orders-board.md | 907 ------------------ docs/spikes/search-strategy.md | 181 ---- docs/work-orders/phase-0/README.md | 32 - .../phase-0/adr-vendor-source-of-truth.md | 67 -- .../phase-0/audit-event-contract.md | 106 -- .../work-orders/phase-0/consumer-inventory.md | 85 -- .../phase-0/dar-domain-architecture-review.md | 139 --- .../dar-persisted-vs-derived-matrix.md | 54 -- .../phase-0/data-ownership-model.md | 75 -- .../phase-0/database-drift-report.md | 110 --- .../phase-0/field-ownership-matrix.md | 86 -- .../phase-0/manual-edit-flag-design.md | 87 -- .../phase-0/migration-dry-run-report.md | 58 -- .../phase-0/phase-0-gates-signoff.md | 46 - docs/work-orders/phase-0/rowversion-design.md | 36 - docs/work-orders/phase-0/smoke-checklist.md | 57 -- .../phase-0/volume-discovery-report.md | 96 -- docs/work-orders/phase-1/README.md | 155 --- docs/work-orders/phase-2/README.md | 154 --- docs/work-orders/phase-3/README.md | 174 ---- docs/work-orders/phase-4/README.md | 162 ---- .../phase-4/search-tier-decision.md | 51 - docs/work-orders/phase-5/README.md | 119 --- docs/work-orders/phase-6/README.md | 159 --- docs/work-orders/phase-7/README.md | 245 ----- docs/work-orders/phase-7/cloudwatch-alerts.md | 40 - .../phase-7/lambda-ingest-discovery.md | 93 -- .../phase-7/phase-7-gates-signoff.md | 55 -- scripts/backfill-docstatus.ps1 | 23 - scripts/seed-work-orders-search.ps1 | 56 -- scripts/spike-status-mapping.sql | 90 -- scripts/spike-work-order-type.sql | 96 -- scripts/volume-discovery.ps1 | 61 -- 35 files changed, 4448 deletions(-) delete mode 100644 docs/adr/work-orders-board-api.md delete mode 100644 docs/auditoria-work-orders-api-vs-frontend.md delete mode 100644 docs/roadmap-work-orders-board.md delete mode 100644 docs/spikes/search-strategy.md delete mode 100644 docs/work-orders/phase-0/README.md delete mode 100644 docs/work-orders/phase-0/adr-vendor-source-of-truth.md delete mode 100644 docs/work-orders/phase-0/audit-event-contract.md delete mode 100644 docs/work-orders/phase-0/consumer-inventory.md delete mode 100644 docs/work-orders/phase-0/dar-domain-architecture-review.md delete mode 100644 docs/work-orders/phase-0/dar-persisted-vs-derived-matrix.md delete mode 100644 docs/work-orders/phase-0/data-ownership-model.md delete mode 100644 docs/work-orders/phase-0/database-drift-report.md delete mode 100644 docs/work-orders/phase-0/field-ownership-matrix.md delete mode 100644 docs/work-orders/phase-0/manual-edit-flag-design.md delete mode 100644 docs/work-orders/phase-0/migration-dry-run-report.md delete mode 100644 docs/work-orders/phase-0/phase-0-gates-signoff.md delete mode 100644 docs/work-orders/phase-0/rowversion-design.md delete mode 100644 docs/work-orders/phase-0/smoke-checklist.md delete mode 100644 docs/work-orders/phase-0/volume-discovery-report.md delete mode 100644 docs/work-orders/phase-1/README.md delete mode 100644 docs/work-orders/phase-2/README.md delete mode 100644 docs/work-orders/phase-3/README.md delete mode 100644 docs/work-orders/phase-4/README.md delete mode 100644 docs/work-orders/phase-4/search-tier-decision.md delete mode 100644 docs/work-orders/phase-5/README.md delete mode 100644 docs/work-orders/phase-6/README.md delete mode 100644 docs/work-orders/phase-7/README.md delete mode 100644 docs/work-orders/phase-7/cloudwatch-alerts.md delete mode 100644 docs/work-orders/phase-7/lambda-ingest-discovery.md delete mode 100644 docs/work-orders/phase-7/phase-7-gates-signoff.md delete mode 100644 scripts/backfill-docstatus.ps1 delete mode 100644 scripts/seed-work-orders-search.ps1 delete mode 100644 scripts/spike-status-mapping.sql delete mode 100644 scripts/spike-work-order-type.sql delete mode 100644 scripts/volume-discovery.ps1 diff --git a/docs/adr/work-orders-board-api.md b/docs/adr/work-orders-board-api.md deleted file mode 100644 index 9501406..0000000 --- a/docs/adr/work-orders-board-api.md +++ /dev/null @@ -1,194 +0,0 @@ -# ADR: Work Orders Board API (Fase 1 read-only) - -**Status:** Proposed — spikes G0, G1, G3 concluídas -**Data:** 2026-06-19 -**Deciders:** Backend + FE (aprovação Fase 1 pending) - ---- - -## Contexto - -O Schedule Board (FE) organiza work orders por `ScheduledDate` em visão semanal. O backend atual expõe `GetWorkOrderList` — listagem CRUD paginada sem janela temporal, sem `siteCode`, sem vendor inline, sem `ScheduledDate` na projeção. - -Spikes de referência: - -- [Consumer Audit](../spikes/consumer-audit-getworkorderlist.md) -- [WorkOrderType](../spikes/work-order-type.md) -- [Search Strategy](../spikes/search-strategy.md) - ---- - -## Decisão - -### 1. Endpoint dedicado (sem `projection=`) - -Criar endpoint **separado** para o Schedule Board. **Não alterar** `GetWorkOrderList`. - -```http -GET /api/workorders/GetWorkOrdersBoard - ?scheduledFrom=2026-06-01 - &scheduledTo=2026-06-07 - &assignee=guid1&assignee=guid2 - &search=BK5 - &status=Open&status=InProgress - &locationId=42 -``` - -Rota alias: `GET /api/WorkOrder/GetWorkOrdersBoard` (consistente com controller existente). - -**Rejeitado:** - -- `GET /api/workorders/table` -- `GET /GetWorkOrderList?projection=table` - -### 2. Window-based fetch (sem paginação Fase 1) - -| Abordagem | Fase 1 | -|-----------|--------| -| `scheduledFrom` + `scheduledTo` | **Obrigatório** | -| `page` / `pageSize` | **Ausente** | -| Limite janela | Max **90 dias** (validação server-side) | - -Response envelope: - -```json -{ - "scheduledFrom": "2026-06-01", - "scheduledTo": "2026-06-07", - "totalCount": 42, - "items": [ - { - "row": { /* WorkOrderBoardRowDto */ }, - "presentation": { /* BoardRowPresentation */ } - } - ] -} -``` - -### 3. DTO — domínio vs presentation - -**`WorkOrderBoardRowDto`** — dados persistidos / joináveis: - -```csharp -public record WorkOrderBoardRowDto( - int Id, - string? WoNumber, - string? SiteCode, - string? LocationLabel, - DateOnly? ScheduledDate, - TimeOnly? ScheduledStart, - DateOnly? DueDate, - string? WorkOrderType, - string? ServiceType, - string? AssigneeId, - string? AssigneeName, - string? VendorCompany, - string? VendorTechnician, - string Status, - string? PocName, - string? PocPhone -); -``` - -**`BoardRowPresentation`** — calculado no mapper backend: - -```csharp -public record BoardRowPresentation( - string StatusDisplay, - bool IsPastDue, - string? WorkOrderTypeDisplay -); -``` - -Mapper: `IWorkOrderBoardMapper` centraliza Past Due e type display. - -### 4. Query EF (Fase 1) - -- Filtro base: `istemplate != true` -- Janela: `ScheduledDate >= from && ScheduledDate < to.AddDays(1)` -- Includes: `Locations`, `AssignToUser`, `Dispatches` → `Vendor` (1 vendor por WO — first active dispatch) -- POC: `WorkOrderContacts` (primary) -- Service type: `Trade` ou category join (mínimo: `Trade`) -- **Não incluir** subquery `lastUpdated` (Comments/AuditLog) - -### 5. Search (conforme spike) - -- Search scoped à janela temporal -- Servidor: `StartsWith` em `InternalWONumber`, `SiteCode` -- Count ≤ **300**: retornar semana inteira; FE filtra title/location/serviceType -- Count > 300: exigir termo indexável ou retornar 400 -- Assignee: `assigneeId[]` multi — **não** filtrar por nome - -Índices (migration separada pós-aprovação): - -- `IX_workOrders_ScheduledDate` (P1) -- `IX_workOrders_InternalWONumber`, `IX_workOrders_SiteCode` (P2, após fix tamanho coluna) - -### 6. WorkOrderType - -- Mapear coluna legada `WorkOrderType varchar(50)` na entidade EF -- Expor em `WorkOrderBoardRowDto` -- `IsPastDue` em presentation — **não** confundir com tipo -- Enum C# adiado até amostragem prod - -### 7. Status mapping - -- API retorna status **canônico** (`Open`, `InProgress`, `Completed`, `Cancelled`, `OnHold`) -- `StatusDisplay` via `IWorkOrderStatusMapper` (workshop G2 separado) -- Sem migration de enum DB - ---- - -## Consequências - -### Positivas - -- 1 HTTP call por mudança de semana (FE) -- Contrato estável para board sem acoplar CRUD legado -- Performance previsível (janela + threshold 300) -- Blazor legado inalterado (EF local) - -### Negativas / trade-offs - -- Duplicação parcial de lógica de listagem vs `GetWorkOrderList` (aceitável — bounded contexts distintos) -- Vendor technician pode ser null Fase 1 se dispatch spike incompleto -- Setup DB local necessário para validação integrada - ---- - -## Compliance com spikes - -| Spike | Gate | Resultado | -|-------|------|-----------| -| Consumer Audit | G0 | `GetWorkOrderList` órfão no repo — endpoint dedicado aprovado | -| WorkOrderType | G1 | Mapear coluna EF; Overdue = presentation | -| Search Strategy | G3 | Window-first, threshold 300, StartsWith identificadores | - ---- - -## Fora de escopo Fase 1 - -- PATCH inline edit -- Completion doc, media, flags, reorder -- Search vendor/tech server-side -- Paginação cross-week -- Alteração de `GetWorkOrderList` - ---- - -## Próximos passos (implementação) - -1. Aprovação explícita **Fase 1 only** -2. Migration: `WorkOrderType` + índice `ScheduledDate` -3. `GetWorkOrdersBoard` em `WorkOrderController` + `WorkOrderDataService` -4. DTOs + `IWorkOrderBoardMapper` -5. FE: `useWorkOrdersBoard({ from, to })` substituindo `RAW_ORDERS` - ---- - -## Referências - -- Gap analysis v3: `work_orders_api_gap_analysis_ce735946.plan.md` -- [`WorkOrderController.cs`](../../Api.SeaHavenIndustries/Controllers/WorkOrderController.cs) -- [`WorkOrderDataService.cs`](../../SeaHaven.DataServices/Implementation/WorkOrderDataService.cs) -- [`DispatchController.List`](../../Api.SeaHavenIndustries/Controllers/DispatchController.cs) — padrão janela temporal diff --git a/docs/auditoria-work-orders-api-vs-frontend.md b/docs/auditoria-work-orders-api-vs-frontend.md deleted file mode 100644 index b8a0ce6..0000000 --- a/docs/auditoria-work-orders-api-vs-frontend.md +++ /dev/null @@ -1,299 +0,0 @@ -# Auditoria — API Work Orders vs Frontend (WorkOrders.tsx) - -**Data:** 2026-06-19 -**Fonte da verdade (FE):** `seahaven.desing/src/pages/WorkOrders.tsx` -**Backend auditado:** `seaheven.api` - ---- - -## Resumo executivo - -| Métrica | Valor | -|---------|-------| -| Compatibility Score | **24/100** | -| Backend Readiness | **Not Ready** | -| Confidence | **High** | - -A API atual foi construída para **CRUD legado** (listagem paginada, detalhe rico, dispatch). O frontend `WorkOrders.tsx` define um **Schedule Board operacional** (visão semanal, 14 colunas, edição inline, advanced search, completion doc WO-level). Os contratos são fundamentalmente diferentes. - -**O que funciona hoje:** id, dueDate, location (parcial), CRUD básico, detalhe rico, comments, audit field-based, mídia WO-level, reatribuição, dispatch. - -**O que falta:** listagem window-based, 14 colunas board, PATCH inline, campos derivados, advanced search, completion doc WO-level, jobs Past Due/Carried Over, status 10 labels, WO# 11 dígitos. - ---- - -## Endpoints que existem hoje - -| Endpoint | O que entrega | -|----------|----------------| -| `GET GetWorkOrderList` | Lista **paginada** (default 12): id, número, título, location, priority, status, dueDate, assignedTo, lastUpdated | -| `GET Getworkorders` / `GetworkordersDD` | Lista legada para admin/dropdown | -| `GET GetFilteredWorkorder` / `2` | Filtros legados (assignee, location, priority, status, due date buckets) — carrega tudo em memória | -| `GET GetWorkorderById` / `{id}` | Detalhe: título, descrição, datas, status, problem, trade, POC, attachments, comments, audit, dispatches | -| `POST AddWorkorder` | Criação via form: título, descrição, assignee, dueDate, location, priority, mídia, contacts/categories | -| `POST/PUT EditWorkorder` | Update parcial via form (subset mapeado ao service) | -| `POST ChangeStatus` | Muda status (string livre) + audit | -| `POST ChangeAssignment` | Reatribui dispatcher + audit | -| `POST AddComment` / `AddCommentJson` | Comentário + upload opcional | -| `GET GetComments` / `GetCommentsByWorkorderId` | Lista comentários | -| `DELETE DeleteWorkorder` | **Delete físico** (não soft cancel) | -| Dispatch endpoints | Vendor, checklist, signoff, verify — **nível dispatch**, não board | - -**Lookups relacionados (outros controllers):** `Common/GetUsers`, `Location/GetLocationList`, `Vendor/GetVendorList` — paginados, contrato diferente do FE. - -**Documentado mas não implementado no código:** `GET GetWorkOrdersBoard`, `IWorkOrderBoardMapper`, `IWorkOrderStatusMapper` (ver `docs/adr/work-orders-board-api.md`). - ---- - -## 1. Listagem da tela principal (visão semanal) - -| Frontend espera | API entrega hoje | Gap | -|-----------------|------------------|-----| -| 1 call por semana (`weekStart` / `weekEnd`) | Paginação `page` / `pageSize` sem janela temporal | Sem fetch window-based | -| WOs agrupados por dia (Mon–Fri) | Lista flat paginada | Sem `scheduledDate` na projeção de listagem | -| Seção **Unscheduled** sempre visível no topo | Só WOs da página atual | Sem WOs sem data retornados independente da semana | -| `targetWeek` (scheduling week-only) | Campo inexistente na entidade/API | Não implementado | -| Contador `X of Y` da semana ativa | `totalCount` global paginado | Contagem inadequada para o board | -| Skeleton Mon–Fri vazios | N/A (FE-only) | OK — responsabilidade do FE | - -**Evidência BE:** `WorkOrderDataService.GetWorkOrderListPagedAsync` — projeção L163-183, sem `ScheduledDate`, `SiteCode`, vendor, type. - ---- - -## 2. Colunas da tabela (`COLS`) - -Definidas no FE em `WorkOrders.tsx` L4234-4249. - -| Coluna FE | Campo(s) FE | API hoje | Gap | -|-----------|-------------|----------|-----| -| Grip | drag reorder | Ausente | FE local-only; BE não persiste ordem | -| Flag | flag pessoal | Ausente | FE session-only | -| **SITE** | `site`, `pocName`, `pocPhone`, `pocNotes` | List: `location` (name); Detail: POC via `WorkOrderContacts` | Sem `siteCode` na listagem; sem POC na lista | -| **WO** | `woNumber`, `rescheduleCount`, `carriedOver` | `internalWONumber` / `workerOrderNumber` | Formato ≠ 11 dígitos; sem badges ↻ ↷N | -| **TYPE OF WO** | `type` (PM / Reactive / Emergency / Add-On / Overdue) | Não exposto (`WorkOrderType` em `db.txt`, não mapeado em EF) | Coluna inoperante | -| **ASSIGNED TO** | `dispatcherId` + avatar/cor | `assignedTo` (nome completo); `AssignTo` = GUID Identity | Sem id/initials/color para avatar | -| **SCHEDULE ON** | `scheduledOn`, `targetWeek` | Detail: `scheduledDate`; **list: ausente** | Listagem sem data de agendamento | -| **DUE DATE** | `dueDate` | `dueDate` | **OK** na listagem | -| **SERVICE** | `pm` | Detail: `trade` / `problem`; list: ausente | Coluna vazia na lista | -| **VENDOR** | `company`, `tech`, `techPhone` | Detail: `dispatches` → vendor; list: ausente | Coluna vazia na lista | -| **APPT TIME** | `apptTime` (ex: "07:00 – 09:00") | Entidade: `ScheduledStart`; sem `ScheduledEnd` | Janela de horário incompleta | -| **STATUS** | 10 labels + overlay Past Due | String legada DB + enum 5 valores API | Sem mapping para status operacionais do FE | -| **COMP DOC** | `docStatus` (Yes / No / NN) | Ausente no WO | Completion existe só no **dispatch** (`VerifyDispatch`) | -| Actions | View / Edit | `GetWorkorderById` separado | Detalhe existe; contrato diferente | - ---- - -## 3. Filtros e busca - -### Barra principal (FE L5083-5099) - -| Frontend | API hoje | Gap | -|----------|----------|-----| -| **Dispatcher** multi-select + `__unassigned__` + default usuário logado | `assignee` single string; `__unassigned` suportado em `GetWorkOrderList` | Multi-select e default "My WOs" não cobertos | -| **Semana ativa** | Sem filtro `ScheduledDate` | Filtro semanal inexistente | -| **Tipo** (All / PM / Reactive / Emergency / Add-On / Overdue) | Sem filtro por work order type | Inexistente | -| **Busca contextual** na semana (site, wo#, dispatcher, location, pm, company, tech, status) | Search em 5 campos (`InternalWONumber`, `WorkerOrderNumber`, `Title`, `Location`), escopo global paginado | Campos e escopo incompatíveis | - -### Advanced Search (FE L4255-4324) - -| Frontend | API hoje | Gap | -|----------|----------|-----| -| Lista plana cross-week | Ausente | Sem endpoint dedicado | -| Date range (this-week, last-week, this-month, last-3-months, next-week, next-month, custom) | `GetFilteredWorkorder` com due date buckets legados | Semântica diferente | -| Filtros: sites[], types[], dispatchers[], statuses[], pmTypes[], vendorTechs[], docs[] | Parcial em endpoints legados | Cobertura incompleta | - ---- - -## 4. Edição inline (spreadsheet-style) - -| Frontend | API hoje | Gap | -|----------|----------|-----| -| PATCH por campo (site, wo#, type, dispatcher, schedule, due, service, vendor, appt, status…) | `EditWorkorder` (form multipart) + `ChangeStatus` + `ChangeAssignment` separados | Sem update granular por campo | -| Auto-schedule: `Incomplete` → `Scheduled` quando data + dispatcher | Ausente no BE | Regra `maybeAutoSchedule` só no FE | -| `rescheduleCount++` ao mudar `scheduledOn` | Campo inexistente | Badge ↻ impossível | -| Limpar `isPastDue` ao reagendar para data futura | Campo derivado inexistente | Past Due não limpa via API | -| Bloquear mudança de status se `isPastDue` | `ChangeStatus` aceita qualquer string | Sem validação 422 | -| WO# único 11 dígitos normalizado | Gerador `WO-{yyyyMMdd}-{random}` ou sync sequencial `10000001` | Formato e unicidade incompatíveis | -| WO cancelado read-only | Sem enforcement no BE | Só lógica no FE (`WOSlideOver` L2380) | - -**Evidência BE:** `WorkOrderController.Editworkorder` L182-192 mapeia subset para `UpdateWorkOrderDTO` — ignora `Problem`, `Trade`, `ScheduledDate`, `Source` apesar de existirem em `EditWorkorder_DTO`. - ---- - -## 5. Criação de WO - -| Frontend | API hoje | Gap | -|----------|----------|-----| -| Wizard `NewWOWizard` + inline `InlineRow` | `POST AddWorkorder` | Campos do wizard majoritariamente ausentes | -| Campos: site, type, scheduledOn, targetWeek, pm, company, tech, appt, POC | `CreateWorkOrderDTO`: title, description, assignTo, dueDate, location | Contrato incompleto | -| Status inicial `Incomplete` | Cria com `WorkOrderStatus.Open` | Status inicial diferente | -| `scheduleWeekOnly` / `targetWeek` | Ausente | Week-only scheduling impossível | -| Navegação automática para semana do WO criado | Depende de listagem semanal | Listagem incompatível | - ---- - -## 6. Cancelamento - -| Frontend | API hoje | Gap | -|----------|----------|-----| -| Soft cancel → status `Canceled` | `POST ChangeStatus(id, "Canceled")` manual | Sem endpoint `POST cancel` dedicado | -| WO cancelado não editável | Sem bloqueio no BE | Enforcement só no FE | -| Delete permanente (Admin) | `DELETE DeleteWorkorder` — delete físico | Comportamento diverge do soft cancel FE | - ---- - -## 7. Slide-over (`WOSlideOver`) - -| Tab FE | API hoje | Gap | -|--------|----------|-----| -| **Info** | `GET GetWorkorderById` | Omite `siteCode`; sem campos derivados FE (`isPastDue`, `rescheduleCount`, etc.) | -| **Comments** | `AddComment` + `GetCommentsByWorkorderId` | Formato FE `{authorId, text, time}` vs BE `{Commenttext, FirstName, Documents}` | -| **Audit Log** | `WorkOrderAuditLog` retornado no detalhe | Schema `{fieldName, oldValue, newValue, action}` vs FE `{type: manual\|system, dispatcherId?, action, time}` | -| **Completion Doc** | `SignOffName` / `SignOffAttachment` no WO; `VerifyDispatch` no dispatch | Sem template por service type, PDF, `docStatus` WO-level | -| **Extra Docs / Media** | `BeforPhoto`, `AfterPhoto`, `workOrderAttachments` | Sem `MediaFile.category`; sem API extra docs dedicada | - ---- - -## 8. Regras de sistema (background jobs) - -| Frontend assume | API hoje | Gap | -|-----------------|----------|-----| -| Job Past Due: `scheduledOn < hoje` + não terminal → `isPastDue=true` | Ausente (zero `BackgroundService` / Hangfire / Quartz no repo) | Flag nunca setada automaticamente | -| Job Carried Over: virada de semana → `carriedOver++` | Ausente | Contador ↷N nunca incrementado | -| Type Overdue promotion | Ausente | Filtro/tipo Overdue não automático | -| Sync APM | `SyncController` POST manual (DynamoDB → SQL) | On-demand, não integrado à UX do board | - ---- - -## 9. Lookups (catálogos) - -| FE usa (mock estático) | API relacionada | Gap | -|------------------------|-----------------|-----| -| `DISPATCHERS` (id, name, initials, color) | `Common/GetUsers` | Sem initials/color; paginado; ids são GUIDs | -| `SITE_OPTIONS` (code → city, state) | `Location/GetLocationList` | Sem lookup por site code; sem mapeamento BK5 → Dallas | -| `PM_TYPES` | Ausente | Sem catálogo de service types | -| `TECHNICIANS` (name, company, phone) | `Vendor/GetVendorList` | Sem endpoint technicians | -| `WEEK_RANGES` (Mon–Fri + flag LIVE) | Ausente | Sem endpoint weeks | - ---- - -## Contrato de dados — campo a campo - -| Campo FE (`WorkOrder`) | Campo BE | Status | -|------------------------|----------|--------| -| `id` | `WorkOrder.Id` | SUPPORTED | -| `woNumber` | `InternalWONumber` / `WorkerOrderNumber` | PARTIALLY_SUPPORTED — formatos conflitantes | -| `site` | `SiteCode` | PARTIALLY_SUPPORTED — entidade tem; API omite na listagem | -| `type` | — (`WorkOrderType` só em SQL) | NOT_SUPPORTED | -| `dispatcherId` | `AssignTo` (GUID) | PARTIALLY_SUPPORTED | -| `scheduledOn` | `ScheduledDate` | PARTIALLY_SUPPORTED — list omite | -| `dayGroup` / `dayLabel` | — | NOT_SUPPORTED (derivado) | -| `location` | `Locations.Name` | SUPPORTED (via join) | -| `pm` | `Trade` / `Problem` | PARTIALLY_SUPPORTED | -| `company` / `tech` | `Dispatches` → `Vendor` | PARTIALLY_SUPPORTED — não inline na lista | -| `techPhone` | — | NOT_SUPPORTED | -| `apptTime` | `ScheduledStart` (sem end) | PARTIALLY_SUPPORTED | -| `status` | `Status` (string livre) | PARTIALLY_SUPPORTED — 10 vs 5+ formatos | -| `docStatus` | — | NOT_SUPPORTED | -| `pocName` / `pocPhone` / `pocNotes` | `WorkOrderContacts` | PARTIALLY_SUPPORTED — notes ausente | -| `dueDate` | `DueDate` | SUPPORTED | -| `rescheduleCount` | — | NOT_SUPPORTED | -| `carriedOver` | — | NOT_SUPPORTED | -| `originalWeek` / `originalDate` | — | NOT_SUPPORTED | -| `isPastDue` | — | NOT_SUPPORTED (derivado) | -| `targetWeek` | — | NOT_SUPPORTED | - ---- - -## Regras de negócio — FE vs BE - -| Regra FE | Evidência FE | BE hoje | Status | -|----------|--------------|---------|--------| -| Auto-schedule Incomplete → Scheduled | `maybeAutoSchedule` L500-506 | Ausente | NOT_SUPPORTED | -| Reschedule incrementa contador | `updateScheduledOn` L4856 | Ausente | NOT_SUPPORTED | -| Reschedule limpa Past Due | L4851-4857 | Ausente | NOT_SUPPORTED | -| Status bloqueado em Past Due | `StatusCell` L1120-1159 | `ChangeStatus` sem validação | NOT_SUPPORTED | -| WO# único 11 dígitos | `findDuplicateWONumber` L4897 | Gerador random; `InternalNumberExistsAsync` sem normalização | NOT_SUPPORTED | -| Cancel → Canceled, read-only | `cancelWO` L4946; `WOSlideOver` L2380 | `ChangeStatus` manual | PARTIALLY_SUPPORTED | -| Carried over job semanal | Audit derivado L2414-2416 | Sem job | NOT_SUPPORTED | -| Completion doc por tipo de serviço | `CompDocDialog` L3902+ | Dispatch verify only | NOT_SUPPORTED (WO-level) | -| Emergency/Reactive → media flow | `isMediaWO` L332-334 | Mídia WO genérica | PARTIALLY_SUPPORTED | - ---- - -## Diagrama — contrato atual vs esperado - -``` -HOJE (GetWorkOrderList) FRONTEND (WorkOrders.tsx) -───────────────────── ───────────────────────── -Paginação 12/page 1 call = semana inteira -Sem ScheduledDate Agrupamento Mon–Fri -Sem siteCode, type, vendor 14 colunas preenchidas -Sem unscheduled semantics Seção Unscheduled fixa -5 status legados 10 status + Past Due overlay -CRUD admin Schedule Board operacional -EditWorkorder (form) PATCH inline por célula -``` - ---- - -## Cobertura por área - -| Área | Cobertura estimada | -|------|-------------------| -| Listagem board (visão semanal) | ~5% | -| Colunas da tabela (14) | ~15% | -| Filtros e busca | ~10% | -| Edição inline | ~0% | -| Criação (wizard/inline) | ~25% | -| Slide-over (detalhe) | ~40% | -| Completion doc WO-level | ~0% | -| Background jobs | ~0% | -| Lookups/catálogos | ~20% | - ---- - -## Critical blockers - -1. **Sem API window-based** para visão semanal + Unscheduled — tela principal não carrega. -2. **Contrato de listagem incompatível** com 14 colunas do board. -3. **Campos persistidos ausentes** — `rescheduleCount`, `carriedOver`, `targetWeek`, `docStatus`, `isPastDue`. -4. **Sem PATCH inline** — edição spreadsheet-style impossível. -5. **Advanced search cross-week inexistente.** -6. **Status 10 valores + Past Due** sem mapping layer implementado. -7. **Completion doc WO-level inexistente** — coluna COMP DOC inoperante. -8. **WO# 11 dígitos único** não suportado. -9. **Jobs Past Due / Carried Over ausentes.** - ---- - -## Non-critical gaps - -- Reorder intra-dia persistido (FE já local-only) -- Flags pessoais (FE session-only) -- Paginação 12/24/48/96 (FE não implementou) -- Bulk select (FE não implementou) -- `[Authorize]` comentado no `WorkOrderController` (risco de segurança) - ---- - -## Referências - -- Frontend: `seahaven.desing/src/pages/WorkOrders.tsx` -- Controller: `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` -- Data service: `SeaHaven.DataServices/Implementation/WorkOrderDataService.cs` -- Entidade: `Data.SeaHavenIndustries/Models/WorkerOrder.cs` -- ADR (proposto, não implementado): `docs/adr/work-orders-board-api.md` -- Spikes: `docs/spikes/consumer-audit-getworkorderlist.md`, `search-strategy.md`, `status-mapping.md`, `work-order-type.md` - ---- - -## Classificação final - -| Métrica | Valor | -|---------|-------| -| Compatibility Level | **Minimally Compatible** | -| Backend Readiness | **Not Ready** | -| Confidence | **High** | - -Este documento reporta apenas **findings** — gaps entre o que a API entrega hoje e o que o frontend especifica. Não inclui propostas de implementação. diff --git a/docs/roadmap-work-orders-board.md b/docs/roadmap-work-orders-board.md deleted file mode 100644 index 4af57f4..0000000 --- a/docs/roadmap-work-orders-board.md +++ /dev/null @@ -1,907 +0,0 @@ -# Roadmap de Implementação — Work Orders API vs Frontend Board - -**Documento:** Relatório consolidado de arquitetura e entrega -**Fonte da verdade (frontend):** `seahaven.desing/src/pages/WorkOrders.tsx` -**Backend:** `seaheven.api` -**Auditoria base:** [auditoria-work-orders-api-vs-frontend.md](auditoria-work-orders-api-vs-frontend.md) -**Estado atual:** Compatibility Score **24/100** — backend **Not Ready** -**Nota técnica do programa:** **9.0/10** (meta kickoff enterprise **9.5+**) -**Escopo:** capacidades de negócio, domínio, governança, fases, riscos e validação — sem design de API, schema ou código - ---- - -## 1. Executive Summary - -### 1.1 Readiness geral - -O backend é **maduro para CRUD legado, dispatch, vendor portal, checklist/sign-off e audit**, mas foi construído para **listagem paginada administrativa**, não para o **Schedule Board operacional** que o frontend define. A tela principal do board **não pode funcionar** com os contratos atuais. - -| Dimensão | Avaliação | -|----------|-----------| -| Tela principal (visão semanal) | ~5% | -| 14 colunas do board | ~15% | -| Edição inline | ~0% | -| Completion doc WO-level | ~0% | -| Background jobs | ~0% | -| Detalhe / slide-over | ~40% | -| **Média ponderada** | **~24%** | - -### 1.2 Problemas arquiteturais principais - -1. **Paradigma de contrato incompatível** — paginação global vs. janela semanal com seção Unscheduled fixa. -2. **Projeção de listagem insuficiente** — 9 das 14 colunas do board não são alimentadas pela listagem atual. -3. **Ausência de camada de domínio operacional** — campos derivados, tipo de WO e status operacionais ausentes ou não expostos. -4. **Database drift** — `WorkOrderType` existe no SQL legado mas não no EF; possíveis outras colunas desalinhadas. -5. **Modelo de mutação inadequado** — form multipart vs. edição granular por célula. -6. **Sem automação de sistema** — zero infraestrutura de jobs; regras do FE nunca executam no servidor. -7. **Cinco consumidores paralelos** — SHOC, Blazor EF, Vendor Portal, Sync/Lambda, e-mails — sem governança de ownership. -8. **Duas trilhas paralelas** — Blazor acessa EF direto; API REST é integração do SHOC. - -### 1.3 Maior risco do programa - -**Governança de ownership de dados** — quem pode alterar cada campo entre SHOC, Vendor Portal, Sync, Blazor e Jobs. A arquitetura de domínio resolve fontes da verdade; a governança operacional é o gate final. - -### 1.4 Decisões de produto registradas - -- **Novas funcionalidades do board** → somente via API REST para o frontend React (SHOC). -- **Blazor (`SeaHavenIndustries`)** → não recebe board, inline edit, jobs, advanced search nem completion doc WO-level; manutenção mínima até sunset do módulo WO. -- **Vendor Portal** → permanece ativo; não-regressão obrigatória. -- **DynamoDB/Sync** → ponte temporária; aposentar após SHOC ingerir direto ([TODO.md](../TODO.md)). - -### 1.5 Complexidade e esforço - -| Métrica | Valor | -|---------|-------| -| Complexidade geral | **Alta** | -| Esforço total | **XL** (6–9 meses, 2 squads; ou 4–6 meses, 3 squads) | -| Fase 0 — Fundação domínio + governança | L | -| Fase 1 — Board semanal | L | -| Fase 2 — Edição inline + concorrência | L | -| Fase 3 — Criação e cancelamento | M | -| Fase 4 — Busca avançada | M | -| Fase 5 — Domain events (jobs) | M | -| Fase 6 — Completion doc e slide-over | M | -| Fase 7 — Rollout e produção | M | - -### 1.6 Gates bloqueantes da Fase 0 - -Nenhuma implementação de board inicia sem: - -1. Domain Architecture Review (DAR) aprovado -2. **Data Ownership Model** assinado -3. **Field Ownership Matrix** assinada -4. **Audit Event Contract** aprovado -5. Volume Discovery Report (sem premissa de volume) -6. Database drift report (SQL vs EF) -7. Spike vendor/dispatch -8. Data migration dry-run -9. RowVersion multi-agregado definido - ---- - -## 2. Capability Gap Assessment - -### 2.1 Weekly Scheduling Board — **Critical** -- **Atual:** paginação sem janela temporal, sem Unscheduled. -- **Desejado:** 1 call/semana, Mon–Fri + Unscheduled, contador `X of Y`. - -### 2.2 Work Order Lifecycle — **High** -- **Atual:** form parcial, status `Open`, delete físico. -- **Desejado:** wizard/inline completo, `Incomplete`, soft cancel read-only. - -### 2.3 Inline Editing — **Critical** -- **Atual:** `EditWorkorder` multipart; endpoints separados. -- **Desejado:** update granular por célula com regras embutidas. - -### 2.4 Search & Filtering — **High** -- **Atual:** 5 campos, escopo global, assignee single-select. -- **Desejado:** busca contextual na semana + advanced search cross-week. - -### 2.5 Status Management — **Critical** -- **Atual:** enum 5 valores + string livre; sem Past Due. -- **Desejado:** 10 labels + flag Past Due separada; auto-schedule; bloqueios. - -### 2.6 Completion Documentation — **High** -- **Atual:** sign-off básico; verify só no dispatch. -- **Desejado:** `docStatus` WO-level (Yes/No/NN); templates por serviço. - -### 2.7 Vendor Management (board) — **High** -- **Atual:** vendor só no detalhe via dispatch. -- **Desejado:** colunas VENDOR/APPT na lista; edição via dispatch primário. - -### 2.8 Scheduling Intelligence — **Critical** -- **Atual:** `ScheduledDate` no detalhe; sem `targetWeek`, `ScheduledEnd`. -- **Desejado:** agendamento semântico completo; badges ↻ e ↷N. - -### 2.9 Audit & Tracking — **Medium** -- **Atual:** audit field-based legado. -- **Desejado:** `{type: manual|system, dispatcherId?, action, time}` + eventos de sistema. - -### 2.10 Background Automation — **Critical** (como otimização, não fonte da verdade) -- **Atual:** zero jobs. -- **Desejado:** WeekRolled, cache opcional PastDue; domínio recalcula on-read. - -### 2.11 Lookup Data — **High** -- **Atual:** lookups paginados incompatíveis; sem PM types, technicians. -- **Desejado:** dispatchers (initials/color), sites por code, PM types, technicians. - -### 2.12 Reporting / Contadores — **Medium** -- **Atual:** `totalCount` global. -- **Desejado:** `X of Y` semanal. - -### 2.13 Security — **High** -- **Atual:** `[Authorize]` comentado no `WorkOrderController`. -- **Desejado:** auth ativa; default "My WOs". - ---- - -## 3. Dependency Map - -```mermaid -flowchart TD - subgraph phase0 [Fase0_Fundacao] - DAR[DAR e 3 artefatos] - DOM[Agregados dominio] - VOL[Volume Discovery] - OWN[Data Ownership] - end - subgraph phase1 [Fase1_Board] - B1[Listagem semanal] - B2[14 colunas] - end - subgraph phase2 [Fase2_Mutacao] - M1[Inline edit] - M2[Concorrencia] - end - subgraph phase3 [Fase3_Lifecycle] - L1[Criacao wizard] - L2[Cancelamento] - end - subgraph phase45 [Fase4_5] - S1[Search] - E1[Domain events] - end - subgraph phase6 [Fase6_Completion] - C1[DocStatus slideover] - end - DAR --> DOM - DOM --> B1 - OWN --> M1 - VOL --> B1 - B1 --> B2 --> M1 - M1 --> L1 - B2 --> S1 - DOM --> E1 - B2 --> C1 -``` - -**Ordem obrigatória:** Fase 0 → Board → Inline → Lifecycle → Search/Events → Completion → Rollout. - ---- - -## 4. Domain Model - -### 4.1 Agregados e limites (anti God Entity) - -```mermaid -flowchart TB - subgraph root [Aggregate Root WorkOrder] - CORE[Core Lifecycle Assignment] - SCH[Scheduling slice] - TRK[Tracking slice] - COMP[Completion slice] - ANA[Analytics slice] - end - subgraph separate [Agregado separado] - DISP[Dispatch fonte vendor] - end - AUD[WorkOrderAuditLog append-only] - CORE --> SCH - CORE --> TRK - CORE --> ANA - CORE --> COMP - CORE --> DISP - CORE --> AUD -``` - -| Agregado | Responsabilidade | -|----------|------------------| -| **WorkOrder (core)** | Id, WoNumber, Type, LifecycleStatus, SiteCode, LocationId, DueDate, AssignTo, Title, RowVersion | -| **Scheduling slice** | ScheduledDate, ScheduledStart/End, TargetWeek, ScheduleWeekOnly | -| **Tracking slice** | OriginalWeek, OriginalDate (set-once) | -| **Analytics slice** | RescheduleCount, CarriedOver (contadores históricos) | -| **Completion slice** | DocStatus, refs attachments | -| **Dispatch** | Vendor, tech, status portal — **fonte da verdade vendor** | -| **Audit** | Rastreabilidade imutável | - -**Proibido:** `VendorId`, `TechName`, `TechPhone` canônicos na WO. - -### 4.2 Scheduling Aggregate Growth Watchlist - -Campos que **não** entram em Scheduling sem ARB review: `MoveReason`, `MoveUser`, `MoveCategory`, `MoveSource`. Metadados de movimentação → Audit. Gate: slice Scheduling > 8 campos operacionais → ARB obrigatório. - -### 4.3 Campos reaproveitados (sem nova coluna) - -`SiteCode`, `ScheduledDate`, `DueDate`, `AssignTo`, `Trade`/`Problem`, `LocationId`, POC via `WorkOrderContacts`. - -### 4.4 Database drift conhecido - -| Coluna SQL | Ação | -|------------|------| -| `WorkOrderType` | Mapear no Core | -| `AvettaTask` | Inventariar; mapear ou deprecar | -| `AssignDate` | Inventariar; mapear ou deprecar | - -Gate Fase 0: diff completo SQL vs `ApplicationDbContextModelSnapshot`. - -### 4.5 Entidades relacionadas - -| Entidade | Mudança | Fase | -|----------|---------|------| -| `WorkOrderContacts` | `Notes` (pocNotes) | 0 | -| `WorkOrderAuditLog` | EventType Manual/System/Vendor/Sync | 0 | -| `ApplicationUser` | Initials, Color | 0–1 | -| `Locations` | SiteCode | 0–1 | -| `WorkOrderAttachments` | Category | 2–6 | -| `WorkOrderEnums` | LifecycleStatus, WorkOrderType, DocStatus, OperationalFlags | 0 | -| `Dispatch` | Proteção regression | contínuo | - -Fora de escopo imediato: `FollowUps`, `Quotes`, `PMSchedules`, `Employee`, `WorkOrderCategories` no board. - ---- - -## 5. Domain Architecture Review (DAR) - -### 5.1 Fonte da verdade por conceito - -| Conceito | Fonte da verdade | Proibido | -|----------|------------------|----------| -| Status | `LifecycleStatus` (10 valores) | String livre; status composto | -| Past Due | `OperationalFlags.PastDue` | Misturar no enum lifecycle | -| Vendor/Tech | Dispatch primário | Duplicar na WO | -| Schedule | Scheduling slice | Duplicar no core | -| Completion | Completion.DocStatus | Só inferir de dispatch | -| Reschedule/Carried | Analytics + audit | Job blind overwrite | -| WO# | Campo canônico único | Dois números sem regra | -| POC | WorkOrderContacts | Só no detalhe | - -### 5.2 Matriz Persistido vs Derivado - -| Campo FE | Persistido | Derivado | Fonte / Regra | -|----------|------------|----------|---------------| -| `lifecycleStatus` | Sim | Não | Core | -| `isPastDue` | Não | **Sim** | `ScheduledDate < hoje` AND NOT terminal; job = cache opcional | -| `type` Overdue | Não | **Sim** | Regra type + schedule + status | -| `carriedOver` | Sim | Não | Métrica histórica; incremento via `WeekRolled` | -| `rescheduleCount` | Sim | Parcial | Domain event em mudança de `ScheduledDate` | -| `docStatus` | Sim | Não | Completion slice | -| `dayGroup`, `apptTime` | Não | Sim | Calculados na leitura | -| vendor/tech | Via Dispatch | Projeção | Join dispatch primário | - -**Regra de ouro:** Jobs nunca são a única fonte da verdade. Leitura sempre recalcula derivados. - -### 5.3 Lifecycle Status vs Operational Flags - -```text -LifecycleStatus → enum único (10 valores FE) -OperationalFlags → PastDue, (futuro: Escalated) -``` - -Filtro status = LifecycleStatus. Overlay Past Due = flag. Bloqueio de edição = validação sobre flag. - -### 5.4 CarriedOver — justificativa - -| | `isPastDue` | `carriedOver` | -|--|-------------|---------------| -| Natureza | Estado pontual | **Métrica histórica acumulada** | -| Persistir | Não (derivado) | **Sim** (contador) | -| Por quê | Calculável da data atual | Não recuperável só do schedule atual | - -Invariante: incremento **somente** via domain event `WeekRolled`. - -### 5.5 Vendor / Dispatch — spike obrigatório (Fase 0) - -1. WO mantém `PrimaryDispatchId` -2. Colunas VENDOR = projeção do dispatch primário -3. Edição inline vendor = mutação no Dispatch -4. WO sem dispatch → edição cria dispatch primário - -Entregável: ADR "Vendor Source of Truth". Critério: zero `Tech*` canônicos na WO. - -### 5.6 Jobs — domínio primeiro - -| Job | Papel | -|-----|-------| -| Past Due diário | Cache refresh opcional; on-read sempre correto | -| Carried Over semanal | Dispara `WeekRolled` → incrementa contador + audit system | -| Promoção Overdue | Derivado on-read | -| Audit | Síncrono em toda mutação desde Fase 0 | - -### 5.7 Database drift investigation - -Diff SQL vs EF; classificar: mapear, deprecar, mover para agregado satélite. Tabelas: `workOrders`, `Comments`, `Locations`, `Dispatch`. - ---- - -## 6. Data Ownership Model - -### 6.1 Ownership por ator - -| Ator | Pode alterar | Não pode alterar | -|------|--------------|------------------| -| **SHOC** | Lifecycle, Schedule, Assignment, Completion, vendor via Dispatch, POC, WO# | Checklist/signoff vendor | -| **Vendor Portal** | Dispatch status, checklist, signoff, comments | Schedule, lifecycle, assignment, DueDate | -| **Sync/Lambda** | Campos ingest (Description, ExternalId, etc.) — só owner Sync | Campos SHOC-owned após 1ª edição manual | -| **Blazor** | Campos legados até sunset | Campos novos board | -| **Jobs** | Incremento CarriedOver; audit system | Lifecycle, Schedule, Dispatch | - -### 6.2 Field Ownership Matrix - -| Campo | Agregado | Owner escritor | Sync sobrescreve? | Conflito SHOC vs Sync | -|-------|----------|----------------|-------------------|----------------------| -| LifecycleStatus | Core | SHOC | Não após 1ª edição SHOC | SHOC vence | -| AssignTo | Core | SHOC | Não após 1ª edição SHOC | SHOC vence | -| ScheduledDate / janela | Scheduling | SHOC | Não | SHOC vence | -| TargetWeek | Scheduling | SHOC | Não | SHOC vence | -| DueDate | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag | -| Description | Core | SHOC + Sync | Merge / SHOC priority | SHOC se editado | -| SiteCode / Location | Core | SHOC + Sync | Sim na criação; não após SHOC | SHOC após flag | -| WorkOrderType | Core | SHOC | Sim na criação ingest | SHOC após flag | -| WoNumber | Core | SHOC | Não | SHOC only | -| Vendor / tech | Dispatch | SHOC + Vendor | Não | Domínios separados | -| Checklist/signoff | Dispatch | Vendor | Não | Vendor vence | -| DocStatus | Completion | SHOC | Não | SHOC only | -| RescheduleCount / CarriedOver | Analytics | Domain events | Não | N/A | -| ExternalWorkOrderId | Core | Sync | Sim (idempotente) | Sync only | - -**ManualEditFlag:** primeira edição SHOC marca campo; Sync subsequente skip + audit `SyncRejected`. - -### 6.3 Sync merge — cenários - -| Cenário | Comportamento | -|---------|---------------| -| Sync DueDate + SHOC ScheduledDate | Merge — campos distintos | -| Sync Status + SHOC Status | SHOC vence; sync skip | -| Sync AssignTo + SHOC AssignTo | SHOC vence | -| Sync Description + SHOC Description | SHOC vence se ManualEditFlag | -| Sync cria WO novo | Full ingest | -| Sync atualiza por ExternalWorkOrderId | Field-level merge por matriz | - ---- - -## 7. Concurrency Strategy - -### 7.1 Atores - -Dispatcher (SHOC), Vendor (Portal), Sync/Lambda, Blazor (congelado), Jobs. - -### 7.2 RowVersion multi-agregado - -| Agregado | RowVersion | Quando muda | -|----------|------------|-------------| -| **WorkOrder (root)** | No Core | Mutação Core, Scheduling, Completion, Analytics na mesma transação | -| **Dispatch** | Próprio | Mutação vendor/tech/checklist/status dispatch | -| **Projeção board** | N/A | FE envia `workOrderVersion` + `dispatchVersion` | - -**Propagação:** -- SHOC edita ScheduledDate → bump WO.RowVersion -- SHOC edita vendor → bump Dispatch.RowVersion -- Vendor edita checklist → bump Dispatch.RowVersion only -- Sync altera Description → bump WO se merge aplicado -- DocStatus → bump WO (Completion na mesma transação) - -Conflito → 409 com `currentState` para refresh. - -### 7.3 Concurrency Ownership Rules - -| Cenário | Resultado | -|---------|-----------| -| SHOC vs SHOC (mesmo campo) | 409; último RowVersion válido | -| SHOC vs Sync (mesmo campo) | Vence owner do campo | -| SHOC vs Sync (campos diferentes) | Merge | -| Vendor vs Sync | Domínios separados | -| SHOC vs Vendor | Merge se campos distintos; impossível mesmo conceito por design | -| Job WeekRolled vs SHOC reschedule | Idempotência WO+semana | -| Blazor vs SHOC | SHOC vence; Blazor congelado | - -### 7.4 Testes - -Fase 2: conflito paralelo (2 dispatchers, sync durante edição); dual RowVersion; audit count = fields changed. - ---- - -## 8. Audit Event Contract - -### 8.1 Princípios - -- Toda mutação gera audit **síncrono** desde Fase 0 -- Edição inline = **1 evento por campo** -- Audit para investigação operacional, não só compliance - -### 8.2 Schema por evento - -| Campo | Obrigatório | -|-------|-------------| -| WorkOrderId | Sim | -| EventType | Sim: Manual / System / Sync / Vendor | -| Action | Sim: FieldChanged, StatusChanged, WeekRolled, SyncRejected | -| FieldName | Sim para inline | -| OldValue, NewValue | Sim | -| ActorId, ActorType | Sim para Manual | -| Timestamp | Sim UTC | -| CorrelationId | Recomendado | -| DispatchId | Quando evento é no dispatch | - -### 8.3 Mutação → eventos - -| Mutação | Eventos | -|---------|---------| -| ChangeStatus | 1× StatusChanged | -| Inline cell | 1× FieldChanged por campo | -| Auto-schedule | StatusChanged + FieldChanged se aplicável | -| Reschedule | FieldChanged + RescheduleCount + audit analytics | -| WeekRolled | System WeekRolled + CarriedOver old/new | -| Sync skip | Sync SyncRejected | -| Vendor checklist | Vendor FieldChanged no Dispatch | - -### 8.4 Contrato FE (Audit tab) - -`{ type, dispatcherId?, action, fieldName?, oldValue, newValue, time }` - ---- - -## 9. Data Migration Plan - -### 9.1 Status - -| Legado | LifecycleStatus alvo | -|--------|------------------------| -| Open | Incomplete | -| InProgress | In Progress | -| Completed | Complete | -| Cancelled | Canceled | -| OnHold | On Hold | -| Desconhecido | Incomplete + NeedsReview | - -Processo: dry-run staging; 100% classificados; `LegacyStatus` read-only 1 release; rollback documentado. - -### 9.2 WO# - -| Formato legado | Estratégia | -|----------------|------------| -| Sequencial sync `10000001` | Normalizar 11 dígitos | -| `WO-{date}-{random}` | Coexistência; novos só 11 dígitos | -| Duplicatas | Resolução manual pré-go-live | - -### 9.3 WorkOrderType - -Mapear SQL existente → enum; NULL → default PO; Overdue = derivado. - -### 9.4 Rollback - -Migrations reversíveis; feature flag SHOC; snapshot pré-migration. - ---- - -## 10. Volume Discovery e Search Scalability - -### 10.1 Volume Discovery (Fase 0 — gate Fase 1) - -**Sem premissa de volume.** Métricas obrigatórias: - -| Métrica | Gate | -|---------|------| -| Total WOs produção/staging | Obrigatório | -| WOs/semana (p95) | Obrigatório | -| WOs Unscheduled | Obrigatório | -| Crescimento mensal | Desejável | - -### 10.2 Tiers e impacto - -| Tier | Total WOs | Listagem semanal | Advanced search | -|------|-----------|------------------|-----------------| -| **S** | < 25k | Índices simples | SQL filtros compostos | -| **M** | 25k–250k | Índices covering | Paginação obrigatória | -| **L** | 250k–1M | Read model candidato | Full-text | -| **XL** | > 1M | Materialized view | Search dedicado (ARB) | - -Fases 1–3: índices conservadores compatíveis com qualquer tier. Fase 4: implementação conforme tier + load test. - ---- - -## 11. Coexistência Multi-Consumidor - -### 11.1 Inventário - -| Consumidor | Conexão | Status | -|------------|---------|--------| -| **SHOC React** (`seahaven.desing`) | REST JWT | Em desenvolvimento — **único alvo novas features** | -| **Blazor** (`SeaHavenIndustries`) | EF direto, não REST | Ativo — manutenção mínima; sunset WO | -| **Vendor Portal UI** | REST `X-Vendor-Token` | API ativa; UI externa | -| **SyncController + Lambda** | DynamoDB → SQL | Ponte temporária | -| **E-mails SendGrid** | Deep links SHOC/Portal | Ativo | -| **Mobile** | — | Sem evidência — confirmar com PO | - -```mermaid -flowchart TB - SHOC[SHOC React] -->|REST JWT| API[Api REST] - VPortal[Vendor Portal] -->|X-Vendor-Token| API - Lambda[Lambda ingest] --> DynamoDB[(DynamoDB)] - DynamoDB --> Sync[SyncController] - Sync --> DB[(SQL Server)] - API --> DB - Blazor[Blazor EF] -->|legado| DB -``` - -**Nota:** DynamoDB é **ingestão externa**, não banco operacional. SQL Server é fonte para board, Blazor e Portal. - -### 11.2 Matriz de coexistência - -| Consumidor | Novas features board | Schema | Sunset | -|------------|---------------------|--------|--------| -| SHOC | Recebe tudo | Consome novos campos | Destino final | -| Blazor | Não recebe | Colunas nullable; backward compatible | Módulo WO congela | -| Vendor Portal | Não usa board | Dispatch intacto | Mantém | -| Sync/Lambda | Indireto | Defaults para novos campos | Após cutover API | -| E-mails | Links SHOC | FrontendBaseUrl prod | Atualizar templates | - -### 11.3 Trilhas de entrega - -- **Trilha A SHOC:** fases 0–7; feature flags; piloto dispatchers -- **Trilha B Blazor:** smoke EF pós-migration; sem board; bugfix crítico only -- **Trilha C Vendor Portal:** regression suite cada release que toca WO/Dispatch -- **Trilha D Lambda:** manter até SHOC produção; cutover Fase 7; critério: criação WO + auth serviço estáveis - -### 11.4 Endpoints legado vs board - -- Legado (`GetWorkOrderList`, etc.): manter durante coexistência; congelar contrato -- Board: novos contratos; não estender legado com hacks -- Deprecação: após 100% dispatchers SHOC + sunset Blazor WO - -### 11.5 Ordem de rollout - -1. Fase 0 staging — gates + migrations -2. Gate Blazor smoke EF -3. Fase 1 staging — SHOC board feature flag -4. Gate Vendor Portal regression -5. Fases 2–6 incrementais -6. Piloto 1–2 dispatchers -7. Rollout gradual -8. Sunset Blazor WO -9. Cutover Lambda → API -10. Deprecação legado + SyncController - -### 11.6 Monitoramento coexistência - -Métricas por consumidor; drift DynamoDB vs SQL; adoção SHOC vs Blazor; alertas job failure. - -### 11.7 Checklist pré-go-live PO - -- App mobile externo? -- Data sunset Blazor WO -- FrontendBaseUrl produção → SHOC -- Owner Lambda cutover - ---- - -## 12. Implementation Phases - -### Fase 0 — Domain Foundation + Governança - -**Objetivo:** Kickoff enterprise — domínio, ownership, volume, audit antes de board. - -**Escopo:** DAR; 3 artefatos (Ownership, Field Matrix, Audit Contract); Volume Discovery; drift SQL/EF; spike vendor; agregados Core+Scheduling+Tracking+Analytics+Completion; enums; RowVersion root+Dispatch; migration dry-run; ManualEditFlag design; audit baseline ChangeStatus/ChangeAssignment; smoke Blazor/Sync/Portal. - -**Não inicia:** Board API, inline edit, jobs. - -**Validação:** 9 gates assinados; audit granularidade testada; tier volume definido. - ---- - -### Fase 1 — Weekly Board (leitura) - -**Objetivo:** Tela principal carrega semana operacional. - -**Escopo:** Listagem window-based; Unscheduled; 14 colunas como projeção; `X of Y`; filtros (dispatcher multi, `__unassigned__`, My WOs, tipo, semana); `isPastDue` derivado on-read; vendor via dispatch primário; índices conforme tier volume. - -**Validação:** 1 call = semana + Unscheduled; 12/14 colunas mínimo; gate Vendor Portal regression. - ---- - -### Fase 2 — Inline Edit + Concorrência - -**Objetivo:** Edição spreadsheet com regras e conflitos tratados. - -**Escopo:** Update granular; dual RowVersion; audit 1 evento/campo; auto-schedule; rescheduleCount++; bloqueio PastDue flag; cancel read-only; WO# 11 dígitos; testes concorrência. - ---- - -### Fase 3 — Criação e Cancelamento - -**Escopo:** Wizard/inline completo; Incomplete inicial; targetWeek; soft cancel; ManualEditFlag na criação; delete admin documentado. - ---- - -### Fase 4 — Search - -**Escopo:** Busca contextual semana; advanced search cross-week conforme tier; load test; sem implementar advanced sem tier definido. - ---- - -### Fase 5 — Scheduled Domain Events - -**Escopo:** WeekRolled → CarriedOver++; cache opcional PastDue; idempotência; monitoramento falha; on-read sempre correto. - ---- - -### Fase 6 — Completion Doc e Slide-over - -**Escopo:** docStatus WO-level; templates por serviço; Comments/Audit/Media alinhados ao FE. - ---- - -### Fase 7 — Rollout e Produção - -**Escopo:** Piloto; rollout gradual; sunset Blazor WO; cutover Lambda; deprecação legado; UAT; zero P1 duas semanas; dashboards saúde. - ---- - -## 13. Critical Path Analysis - -### Must Have -1. Listagem window-based + Unscheduled -2. Projeção 14 colunas -3. Agregados domínio + enums -4. Update granular por campo -5. Status 10 labels + flag PastDue -6. Lookups -7. Criação wizard -8. WO# 11 dígitos -9. DAR + 3 artefatos Fase 0 - -### Should Have -10. Domain events CarriedOver -11. Advanced search -12. Regras mutação completas -13. Soft cancel enforcement -14. docStatus WO-level -15. Auth ativa - -### Nice to Have -16. Reorder intra-dia persistido (FE local) -17. Flags pessoais session-only -18. Paginação 12/24/48/96 -19. Bulk select - ---- - -## 14. Frontend Impact Assessment - -| Gap | UX | Severidade | Workaround | -|-----|-----|------------|------------| -| Sem listagem semanal | Tela inutilizável | Critical | Mock FE | -| 14 colunas incompletas | Board ilegível | Critical | Nenhum em prod | -| Sem inline edit | Core inoperante | Critical | Modal legado | -| Sem advanced search | WOs históricos invisíveis | High | Busca por ID | -| Status incompatíveis | Decisões erradas | Critical | Hardcode FE | -| Sem jobs (derivados) | Badges stale se só client | High | On-read BE resolve PastDue | -| Sem docStatus | COMP DOC vazia | High | Ocultar coluna | -| WO# formato errado | Criação bloqueada | Critical | — | -| Ownership Sync vs SHOC | Overwrite silencioso | Critical | Field Matrix Fase 0 | -| Auth desabilitada | Risco segurança | High | API gateway | - ---- - -## 15. Business Rules Assessment - -| Regra | Status | Impacto | -|-------|--------|---------| -| Auto-schedule Incomplete→Scheduled | Unsupported | Status incorreto | -| Reschedule → rescheduleCount++ | Unsupported | Badge ↻ ausente | -| Reschedule limpa PastDue (derivado) | Unsupported | Flag stale até re-read | -| Status bloqueado se PastDue | Unsupported | Bypass via API | -| WO# único 11 dígitos | Unsupported | Duplicatas | -| Cancel read-only | Partial | Edição pós-cancel | -| WeekRolled → carriedOver | Unsupported | Badge ↷N ausente | -| Completion doc por serviço | Unsupported | Workflow manual | -| Week-only scheduling | Unsupported | Wizard incompleto | - ---- - -## 16. Data Contract Readiness - -| Área | Readiness % | Gap principal | -|------|-------------|---------------| -| List Views board | 5% | 14 colunas, schedule, vendor, flags | -| Detail slide-over | 40% | Derivados, docStatus, schema | -| Search | 10% | Cross-week, date ranges | -| Lookups | 20% | initials, site code, PM, tech | -| Scheduling | 25% | targetWeek, ScheduledEnd | -| Status | 30% | 10 labels + flag | -| Completion | 0% | docStatus, templates | -| Comments | 60% | Schema | -| Audit | 50% | type system, granularidade | -| Media | 45% | category | - -**Média ponderada: ~28%** - ---- - -## 17. Testing Strategy - -### Functional -Board semanal, 14 colunas, filtros, inline por campo, regras negócio, criação/cancelamento, docStatus, status+flag, ownership rejection sync. - -### Integration -SHOC E2E por fase; Vendor Portal regression; Sync; auth; dual RowVersion. - -### Regression -Endpoints legados; Blazor smoke EF; Portal checklist/signoff. - -### UAT -Roteiros dispatchers reais; sign-off PO por fase. - -### Data validation -WO# migration; status mapping; derivados on-read vs contadores; referencial. - -### Migration validation -Dump produção espelho; antes/depois contagens; rollback por fase. - -### Fase-specific -- **0:** volume queries; audit granularidade; sync skip -- **2:** concurrency suite; 409 handling -- **4:** load test tier -- **5:** job failure injection; on-read correctness - ---- - -## 18. Risk Matrix - -| Risco | Prob. | Impacto | Mitigação | -|-------|-------|---------|-----------| -| Board inoperante em prod | Alta | Crítico | Fase 1 gate; E2E | -| Ownership ambíguo Sync vs SHOC | Alta | Crítico | Field Ownership Matrix | -| God Entity creep | Média | Alto | Growth Watchlist; ARB | -| Conflito cross-agregado | Média | Alto | RowVersion root + Dispatch | -| Job falha estado errado | Alta | Alto | Derivados on-read | -| Vendor drift Dispatch vs WO | Alta | Crítico | Spike; projeção only | -| Status migration incorreta | Média | Crítico | LegacyStatus; dry-run | -| Volume desconhecido → índices errados | Alta | Alto | Volume Discovery Fase 0 | -| Audit inútil investigação | Média | Médio | Event Contract | -| Regressão Vendor Portal | Média | Crítico | Suite dedicada | -| Blazor vs SHOC divergência | Alta | Alto | Congelar Blazor WO | -| Auth bloqueia integrações | Média | Alto | Inventário consumidores | -| CarriedOver recalculado errado | Baixa | Médio | Só via WeekRolled | -| Scheduling aggregate creep | Média | Alto | Watchlist §4.2 | - ---- - -## 19. Prioritized Backlog - -Ordenado por sequência de implementação. - -| # | Item | P | Fase | Dep | Complex. | Risco | -|---|------|---|------|-----|----------|-------| -| 1 | DAR completo + sign-off CTO | P0 | 0 | — | M | Alto | -| 2 | Data Ownership Model | P0 | 0 | — | S | Alto | -| 3 | Field Ownership Matrix + ManualEditFlag | P0 | 0 | 2 | M | Alto | -| 4 | Audit Event Contract + baseline | P0 | 0 | — | M | Médio | -| 5 | Volume Discovery Report | P0 | 0 | — | S | Alto | -| 6 | Database drift SQL vs EF | P0 | 0 | — | M | Médio | -| 7 | Spike vendor/dispatch source of truth | P0 | 0 | — | M | Alto | -| 8 | Agregados domínio (anti God Entity) | P0 | 0 | 1 | L | Alto | -| 9 | Matriz Persistido vs Derivado | P0 | 0 | 1 | S | Médio | -| 10 | RowVersion root + Dispatch | P0 | 0 | 8 | M | Alto | -| 11 | Enums Lifecycle + Flags + WorkOrderType | P0 | 0 | 8 | M | Médio | -| 12 | Data migration dry-run | P0 | 0 | 8 | M | Alto | -| 13 | Inventário consumidores + coexistência | P0 | 0 | — | S | Médio | -| 14 | Smoke Blazor EF + Sync + Portal | P0 | 0 | 8 | M | Alto | -| 15 | Normalização WO# 11 dígitos | P0 | 0 | 12 | M | Alto | -| 16 | Reativar auth endpoints WO | P0 | 0 | — | S | Médio | -| 17 | Lookups (dispatchers, sites, PM, tech) | P0 | 0–1 | — | M | Baixo | -| 18 | Listagem window-based + Unscheduled | P0 | 1 | 8,11,17 | L | Alto | -| 19 | Projeção 14 colunas | P0 | 1 | 18 | L | Alto | -| 20 | isPastDue derivado on-read | P0 | 1 | 19 | S | Baixo | -| 21 | Vendor via dispatch primário | P0 | 1 | 7,19 | M | Alto | -| 22 | Filtros principais + My WOs | P0 | 1 | 18 | M | Médio | -| 23 | Contador X of Y | P1 | 1 | 18 | S | Baixo | -| 24 | Sync merge por field ownership | P0 | 0–1 | 3 | M | Alto | -| 25 | Update granular inline | P0 | 2 | 19 | L | Alto | -| 26 | Audit 1 evento por campo | P0 | 2 | 4,25 | M | Médio | -| 27 | Concurrency integration tests | P0 | 2 | 10,25 | M | Alto | -| 28 | Auto-schedule Incomplete→Scheduled | P0 | 2 | 25 | M | Médio | -| 29 | RescheduleCount++ domain event | P0 | 2 | 25 | S | Baixo | -| 30 | Bloqueio status se PastDue flag | P0 | 2 | 25 | S | Baixo | -| 31 | Cancel read-only enforcement | P1 | 2 | 25 | S | Baixo | -| 32 | Criação wizard/inline completa | P0 | 3 | 25 | M | Médio | -| 33 | Week-only scheduling targetWeek | P1 | 3 | 32 | M | Médio | -| 34 | Soft cancel dedicado | P1 | 3 | 31 | S | Baixo | -| 35 | Busca contextual semana | P1 | 4 | 19 | M | Baixo | -| 36 | Advanced search cross-week | P1 | 4 | 5,19 | L | Médio | -| 37 | Load test search por tier | P0 | 4 | 5 | M | Alto | -| 38 | WeekRolled domain event + CarriedOver | P1 | 5 | 8 | M | Médio | -| 39 | Cache opcional PastDue | P2 | 5 | 20 | S | Baixo | -| 40 | docStatus WO-level | P1 | 6 | 8 | M | Médio | -| 41 | Completion templates por serviço | P2 | 6 | 40 | L | Médio | -| 42 | Slide-over Comments/Audit/Media | P2 | 6 | 4,40 | M | Baixo | -| 43 | Regression suite legado + Portal | P0 | cont. | — | M | Alto | -| 44 | UAT dispatchers por fase | P0 | cont. | — | S | Médio | -| 45 | Piloto + rollout gradual SHOC | P0 | 7 | 1–42 | M | Alto | -| 46 | Sunset Blazor WO | P1 | 7 | 45 | S | Médio | -| 47 | Cutover Lambda → API | P1 | 7 | 32 | M | Alto | -| 48 | Deprecação endpoints legado + Sync | P2 | 7 | 45 | M | Médio | -| 49 | Monitoramento coexistência | P1 | 7 | 45 | S | Baixo | - ---- - -## 20. Cronograma indicativo - -```mermaid -gantt - title Work Orders Board - dateFormat YYYY-MM-DD - section Fundacao - DAR_arteFatos_dominio :2026-07-01, 8w - section Board - Listagem_14colunas :2026-09-01, 8w - section Mutacao - Inline_concorrencia :2026-11-01, 8w - Criacao_cancelamento :2026-11-15, 6w - section Inteligencia - Search :2027-01-01, 6w - Domain_events :2027-01-15, 4w - section Completion - DocStatus_slideover :2027-02-15, 6w - section Producao - Piloto_rollout :2027-04-01, 8w -``` - ---- - -## 21. Stakeholders - -| Papel | Responsabilidade | -|-------|------------------| -| CEO / Sponsor | Investimento XL; prioridade SHOC sobre Blazor | -| Product Owner | Aceite por fase; semana operacional; regras ambíguas | -| CTO / Arquiteto | DAR; 3 artefatos; ownership; cutover Lambda | -| Backend | Fases 0–6 domínio e contratos | -| Frontend SHOC | Integração; feature flags; remover mocks | -| QA | Testes §17; UAT; concurrency | -| Documentação | Contratos; guia consumidores legados | -| Operações | Jobs; alertas; monitoramento coexistência | - ---- - -## 22. Critérios de prontidão para kickoff (9.5+) - -| Área | Critério | -|------|----------| -| Domain Design | DAR aprovado; agregados; Watchlist | -| Source of Truth | Field Ownership Matrix assinada | -| Data Governance | Data Ownership Model completo | -| Concurrency | RowVersion root + Dispatch; testes Fase 2 | -| Sync | Cenários explícitos; ManualEditFlag | -| Audit | Event Contract; 1 evento/campo inline | -| Migration | Dry-run; LegacyStatus rollback | -| Volume | Discovery Fase 0; tier definido | -| Jobs | Domain events; on-read correto se job falha | -| Coexistência | Inventário; trilhas; smoke Blazor/Portal/Sync | - ---- - -## 23. Conclusão - -Programa **viável e de grande porte (XL)**. O caminho crítico é: - -```text -Resolver domínio e governança (Fase 0) - → Board semanal (Fase 1) - → Inline edit + concorrência (Fase 2) - → Paridade funcional progressiva (Fases 3–6) - → Rollout seguro (Fase 7) -``` - -O documento integra: gap assessment da auditoria, modelo de domínio com agregados, governança de ownership, concorrência multi-agregado, migração de dados, coexistência SHOC/Blazor/Portal/Lambda, e backlog executável — pronto para Architecture Review Board e kickoff de implementação enterprise. - -**Próximo passo imediato:** executar Fase 0 — produzir e assinar os três artefatos (Data Ownership Model, Field Ownership Matrix, Audit Event Contract) antes de qualquer migration de schema. diff --git a/docs/spikes/search-strategy.md b/docs/spikes/search-strategy.md deleted file mode 100644 index 273e32d..0000000 --- a/docs/spikes/search-strategy.md +++ /dev/null @@ -1,181 +0,0 @@ -# Spike Search Strategy — `GetWorkOrdersBoard` - -**Data:** 2026-06-19 -**Gate:** G3 (pré-requisito Fase 1) -**Objetivo:** Definir busca mínima viável para o Schedule Board sem degradar performance. - ---- - -## Estado atual (`GetWorkOrderList`) - -Implementação em `SeaHaven.DataServices/Implementation/WorkOrderDataService.cs` (`GetWorkOrderListPagedAsync`): - -| Aspecto | Comportamento atual | -|---------|---------------------| -| Paginação | `page` / `pageSize` (default 12), sem cap | -| Janela temporal | **Ausente** — sem filtro `ScheduledDate` | -| Search | `Contains` + `ToLower()` em 5 campos | -| Campos search | `InternalWONumber`, `WorkerOrderNumber`, `WorkerOrderTitle`, `Locations.Title`, `Locations.Name` | -| Campos omitidos | `SiteCode`, `Trade`, `Problem`, vendor, dispatcher por ID | -| Joins | `Locations`, `AssignToUser` (AspNetUsers) | -| Vendor/dispatch | **Não incluídos** | -| Projeção extra | Subquery `lastUpdated` via Comments + WorkOrderAuditLogs por linha | - -### Padrão SQL gerado (search) - -```sql -WHERE LOWER(InternalWONumber) LIKE '%term%' - OR LOWER(WorkerOrderNumber) LIKE '%term%' - OR LOWER(WorkerOrderTitle) LIKE '%term%' - OR LOWER(Locations.Name) LIKE '%term%' - ... -``` - -Leading wildcard → table scan em colunas `nvarchar(max)`. - ---- - -## Respostas às perguntas do spike - -| # | Pergunta | Decisão Fase 1 | -|---|----------|----------------| -| S1 | Volume típico por semana | Assumir **< 200 WOs/semana** até medir em prod; threshold fallback = **300** | -| S2 | Campos pesquisáveis mínimos | `internalWONumber`, `siteCode`, `assigneeId[]` (exato), `locationId` | -| S3 | LIKE vs full-text | **Sem full-text** Fase 1; `StartsWith` em WO# e siteCode; evitar `Contains` em title/location no servidor | -| S4 | Escopo | Search **sempre scoped** a `scheduledFrom` / `scheduledTo` (obrigatório no endpoint) | -| S5 | Vendor/tech no search | **Fase 2** — exige join `Dispatches → Vendor` | - ---- - -## Estratégia em 2 camadas - -``` -Request GetWorkOrdersBoard - │ - ├─► 1. Filtro janela ScheduledDate (obrigatório) - │ - ├─► 2. Filtros exatos: status, assigneeId[], locationId - │ - ├─► 3. COUNT no escopo - │ - ├─► count <= 300? - │ ├─ SIM → retornar semana inteira (sem paginação Fase 1) - │ │ search textual extra no FE: title, locationLabel, serviceType - │ └─ NÃO → exigir critério indexável no servidor - │ (woNumber ou siteCode StartsWith) - │ - └─► Response: { scheduledFrom, scheduledTo, totalCount, items[] } -``` - -### Campos Fase 1 vs Fase 2 - -| Campo | Fase 1 (servidor) | Fase 1 (client fallback) | Fase 2 | -|-------|-------------------|--------------------------|--------| -| `internalWONumber` / `workerOrderNumber` | `StartsWith` | — | — | -| `siteCode` | `StartsWith` ou igualdade | — | — | -| `assigneeId` | multi-select exato | — | — | -| `locationId` | exato | — | — | -| `status` | exato / multi | pills client-side | — | -| `workerOrderTitle` | — | client-side se count ≤ 300 | server `Contains` opcional | -| `locationLabel` | — | client-side | — | -| `serviceType` (trade/category) | — | client-side | server join | -| `vendorCompany` / `vendorTechnician` | — | — | join Dispatch→Vendor | -| `assigneeName` | — | client-side | evitar join por nome | - ---- - -## Query plan esperado (Fase 1) - -### Caminho feliz (semana típica, ≤ 300 rows) - -1. **Index seek** em `IX_workOrders_ScheduledDate` (proposto) com range `[scheduledFrom, scheduledTo+1day)` -2. Filtro `istemplate != true` -3. Filtros exatos em `AssignTo`, `LocationId`, `Status` (AND) -4. **Sem** search textual no servidor se `search` vazio -5. Join LEFT `Locations`, `AssignToUser`, LEFT `Dispatches` + `Vendor` (Fase 1 board — vendor no DTO, não no search) -6. Projeção flat para `WorkOrderBoardRowDto` — **sem** subquery `lastUpdated` - -### Caminho search servidor (count > 300 ou user digitou termo indexável) - -1. Mesma janela + filtros exatos -2. AND (`InternalWONumber LIKE 'term%'` OR `SiteCode LIKE 'term%'`) -3. Se count ainda > 300 → HTTP 400 com mensagem "Refine search or narrow date window" - -### Anti-padrões proibidos no board endpoint - -- `pageSize=500` como gambiarra de fetch semanal -- `Contains('%x%')` em múltiplas tabelas sem janela temporal -- Filtro assignee por nome concatenado (`FirstName + LastName`) — usar `assigneeId` -- Subquery correlated Comments/AuditLog por row - ---- - -## Índices recomendados - -Migration separada **após aprovação** desta spike (não incluir na Fase 1 code sem review): - -| Índice | Coluna(s) | Prioridade | Nota | -|--------|-----------|------------|------| -| `IX_workOrders_ScheduledDate` | `ScheduledDate` | **P1** | Filtro de janela do board | -| `IX_workOrders_InternalWONumber` | `InternalWONumber` | P2 | Requer alterar coluna de `nvarchar(max)` → `nvarchar(50)` | -| `IX_workOrders_SiteCode` | `SiteCode` | P2 | Idem — tamanho fixo ~50 | -| `IX_workOrders_AssignTo` | `AssignTo` | existente | Multi-select dispatcher | -| `IX_workOrders_LocationId` | `LocationId` | existente | Filtro location | - -**Full-text index:** não recomendado Fase 1 — volume semanal baixo + fallback client-side suficiente. - ---- - -## Referência de implementação - -Copiar padrão de janela temporal de `DispatchController.List`: - -```csharp -if (dateFrom.HasValue) - q = q.Where(d => d.DispatchedAt >= dateFrom.Value); -if (dateTo.HasValue) -{ - var end = dateTo.Value.Date.AddDays(1); - q = q.Where(d => d.DispatchedAt < end); -} -``` - -Adaptar para `WorkOrder.ScheduledDate` no board endpoint. - ---- - -## Parâmetros propostos — `GetWorkOrdersBoard` - -| Parâmetro | Tipo | Obrigatório | Descrição | -|-----------|------|-------------|-----------| -| `scheduledFrom` | `DateOnly` | **Sim** | Início da semana/janela | -| `scheduledTo` | `DateOnly` | **Sim** | Fim da janela (inclusivo) | -| `search` | `string` | Não | WO# ou siteCode prefix | -| `assignee` | `string[]` | Não | IDs de dispatcher (multi) | -| `status` | `string[]` | Não | Status canônico API | -| `locationId` | `int?` | Não | Filtro location | - -**Limite hard janela:** max **90 dias** (Fase 2 advanced search cross-week). - ---- - -## Threshold e fallback - -| Constante | Valor | Justificativa | -|-----------|-------|---------------| -| `BoardSearchClientSideThreshold` | **300** | Payload ~300 rows × ~500 bytes ≈ 150 KB — aceitável para 1 call/semana | -| Sem paginação Fase 1 | — | Janela semanal é o filtro natural | -| Paginação Fase 2 | cursor/offset | Só quando janela > 1 semana com search global | - ---- - -## Conclusão - -Fase 1 adota **window-first, search-second**: - -1. Janela `ScheduledDate` obrigatória + índice dedicado -2. Search servidor mínimo (`StartsWith` em identificadores) -3. Fallback client-side para campos ricos quando count ≤ 300 -4. Vendor/tech e full-text adiados para Fase 2 - -Esta estratégia desbloqueia `GetWorkOrdersBoard` sem replicar os anti-padrões de `GetWorkOrderListPagedAsync`. diff --git a/docs/work-orders/phase-0/README.md b/docs/work-orders/phase-0/README.md deleted file mode 100644 index 239469e..0000000 --- a/docs/work-orders/phase-0/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Fase 0 — Work Orders Foundation - -Documentação e gates da Fase 0 (Domain Foundation + Governança). - -## Governança - -- [DAR — Domain Architecture Review](dar-domain-architecture-review.md) -- [Matriz Persistido vs Derivado](dar-persisted-vs-derived-matrix.md) -- [Data Ownership Model](data-ownership-model.md) -- [Field Ownership Matrix](field-ownership-matrix.md) -- [ManualEditFlag Design](manual-edit-flag-design.md) -- [Audit Event Contract](audit-event-contract.md) - -## Discovery - -- [Volume Discovery Report](volume-discovery-report.md) -- [Database Drift Report](database-drift-report.md) -- [Consumer Inventory](consumer-inventory.md) -- [ADR Vendor Source of Truth](adr-vendor-source-of-truth.md) - -## Validação - -- [RowVersion Design](rowversion-design.md) -- [Migration Dry-Run Report](migration-dry-run-report.md) -- [Smoke Checklist](smoke-checklist.md) -- [Gates Sign-Off](phase-0-gates-signoff.md) - -## Código entregue - -- Migration: `Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.cs` -- Serviços: `IWorkOrderAuditService`, `IWorkOrderFieldLockService`, `ISyncFieldMergePolicy` -- Testes: `SeaHavenIndustries.Tests` (12 testes) diff --git a/docs/work-orders/phase-0/adr-vendor-source-of-truth.md b/docs/work-orders/phase-0/adr-vendor-source-of-truth.md deleted file mode 100644 index 6dbafa9..0000000 --- a/docs/work-orders/phase-0/adr-vendor-source-of-truth.md +++ /dev/null @@ -1,67 +0,0 @@ -# ADR — Vendor Source of Truth - -**Status:** Accepted (Fase 0 spike) -**Decisores:** Backend + Arquiteto - ---- - -## Contexto - -O board SHOC exibe colunas VENDOR e APPT na listagem semanal. Hoje vendor existe apenas no agregado Dispatch; a entidade WorkOrder não possui referência ao dispatch primário. - ---- - -## Decisão - -1. **WorkOrder.PrimaryDispatchId** (nullable FK → Dispatches) identifica o dispatch canônico para projeção board. -2. Colunas VENDOR/APPT = **projeção read-only** via join no dispatch primário (Vendor.Name, Dispatch.ScheduledDate). -3. Edição inline vendor (Fase 2) muta **Dispatch**, não WorkOrder. Bump `Dispatch.RowVersion`. -4. WO sem dispatch: primeira edição vendor cria dispatch primário e seta `PrimaryDispatchId`. -5. **Proibido** adicionar VendorId, TechName, TechPhone na tabela workOrders. - ---- - -## Query de projeção (spike) - -```sql -SELECT wo.Id, - wo.InternalWONumber, - d.Id AS DispatchId, - v.Name AS VendorName, - d.ScheduledDate AS ApptDate, - d.Status AS DispatchStatus -FROM workOrders wo -LEFT JOIN Dispatches d ON d.Id = wo.PrimaryDispatchId -LEFT JOIN Vendors v ON v.Id = d.VendorId -WHERE wo.IsDeleted IS NULL OR wo.IsDeleted = 0; -``` - -Implementação C# em `DispatchDataService.GetPrimaryDispatchProjectionAsync(workOrderId)` (Fase 1). - ---- - -## Seleção do dispatch primário - -| Cenário | Regra | -|---------|-------| -| PrimaryDispatchId setado | Usar esse dispatch | -| Null + exatamente 1 dispatch | Auto-set PrimaryDispatchId na migration backfill | -| Null + N dispatches | Usar dispatch mais recente por DispatchedAt; log para revisão manual | -| Zero dispatches | Vendor columns vazias no board | - ---- - -## Consequências - -- **Positivo:** Zero drift vendor WO vs Dispatch; Portal regression isolada. -- **Negativo:** Join extra na listagem board — mitigado por índice em PrimaryDispatchId. -- **Fase 0:** Coluna + FK + backfill script no dry-run; endpoint board na Fase 1. - ---- - -## Critérios de aceite spike - -- [x] ADR documentado -- [x] Query prototipada -- [ ] PrimaryDispatchId na migration Phase0 -- [ ] Backfill documentado no dry-run report diff --git a/docs/work-orders/phase-0/audit-event-contract.md b/docs/work-orders/phase-0/audit-event-contract.md deleted file mode 100644 index bb773f4..0000000 --- a/docs/work-orders/phase-0/audit-event-contract.md +++ /dev/null @@ -1,106 +0,0 @@ -# Audit Event Contract — Work Orders - -**Fase:** 0 -**Status:** Aprovado para implementação baseline -**Schema EF:** `WorkOrderAuditLog` estendido - ---- - -## 1. Princípios - -- Toda mutação gera audit **síncrono** desde Fase 0. -- Edição inline (Fase 2) = **1 evento por campo**. -- Audit serve investigação operacional, não só compliance. - ---- - -## 2. Schema por evento - -| Campo | Tipo | Obrigatório | -|-------|------|-------------| -| Id | int | Sim (PK) | -| WorkOrderId | int | Sim | -| EventType | enum string | Sim: Manual, System, Sync, Vendor | -| Action | string | Sim: FieldChanged, StatusChanged, WeekRolled, SyncRejected, AssignmentChanged | -| FieldName | string | Sim para inline / field change | -| OldValue | string | Sim | -| NewValue | string | Sim | -| UserId / ActorId | string | Sim para Manual | -| ActorType | enum string | Sim: Dispatcher, Vendor, System, Sync | -| CreatedAt | DateTime UTC | Sim | -| CorrelationId | string | Recomendado | -| DispatchId | int? | Quando evento é no dispatch | - -**Compatibilidade:** colunas `Action` legada mapeada para novo `Action`; `UserId` = ActorId para Manual. - ---- - -## 3. Enums - -```csharp -AuditEventType: Manual | System | Sync | Vendor -AuditActorType: Dispatcher | Vendor | System | Sync -AuditActionType: FieldChanged | StatusChanged | AssignmentChanged | WeekRolled | SyncRejected | Create | Delete -``` - ---- - -## 4. Mutação → eventos - -| Mutação | Eventos | -|---------|---------| -| ChangeStatus | 1× StatusChanged (EventType=Manual) | -| ChangeAssignment | 1× AssignmentChanged (FieldName=AssignTo) | -| Inline cell (Fase 2) | 1× FieldChanged por campo | -| Auto-schedule (Fase 2) | StatusChanged + FieldChanged se aplicável | -| Reschedule (Fase 2) | FieldChanged + analytics | -| WeekRolled (Fase 5) | System WeekRolled + CarriedOver | -| Sync skip | Sync SyncRejected | -| Vendor checklist | Vendor FieldChanged + DispatchId | - ---- - -## 5. Contrato FE (Audit tab) - -```typescript -interface AuditEntry { - type: 'manual' | 'system' | 'sync' | 'vendor'; - dispatcherId?: string; - action: string; - fieldName?: string; - oldValue: string; - newValue: string; - time: string; // ISO UTC - dispatchId?: number; -} -``` - -Mapeamento API → FE: - -| API EventType | FE type | -|---------------|---------| -| Manual | manual | -| System | system | -| Sync | sync | -| Vendor | vendor | - ---- - -## 6. Baseline Fase 0 (endpoints) - -| Endpoint | Garantia | -|----------|----------| -| POST ChangeStatus | 1 audit StatusChanged; Old/New preenchidos | -| POST ChangeAssignment | 1 audit AssignmentChanged; nomes usuário em Old/New | - -Implementação via `IWorkOrderAuditService`. - ---- - -## 7. Critérios de aceite - -- [ ] Migration estende WorkOrderAuditLog -- [ ] ChangeStatus/ChangeAssignment usam serviço central -- [ ] Testes assertam 1 evento por mutação - -**Assinatura Backend Lead:** _________________ Data: _______ diff --git a/docs/work-orders/phase-0/consumer-inventory.md b/docs/work-orders/phase-0/consumer-inventory.md deleted file mode 100644 index 5d1e35d..0000000 --- a/docs/work-orders/phase-0/consumer-inventory.md +++ /dev/null @@ -1,85 +0,0 @@ -# Consumer Inventory — Work Orders - -**Fase:** 0 -**Referência:** roadmap §11 - ---- - -## 1. Inventário de consumidores - -| Consumidor | Conexão | Código principal | Campos mutados | Audit hoje? | -|------------|---------|------------------|----------------|-------------| -| **SHOC React** | REST JWT | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` | Status, AssignTo, CRUD parcial via Service | Parcial (ChangeStatus/Assignment) | -| **Blazor** | EF direto | `SeaHavenIndustries/Data/Services/WorkorderService.cs` | CRUD completo, comments, status | **Não** | -| **Vendor Portal** | REST token | `Api.SeaHavenIndustries/Controllers/VendorPortalController.cs` | Dispatch checklist, signoff, status | Sim (ad hoc) | -| **Sync/Lambda** | DynamoDB → SQL | `Api.SeaHavenIndustries/Controllers/SyncController.cs` | Upsert WO por ExternalWorkOrderId | **Não** | -| **Dispatch API** | REST | `DispatchController.cs` | Dispatch create/update/verify | Sim | -| **E-mail SendGrid** | Deep links | `Helper/SendMessage` | Nenhum (read-only links) | N/A | - ---- - -## 2. Diagrama - -```mermaid -flowchart TB - SHOC[SHOC_React] -->|REST_JWT| API[Api_REST] - VPortal[Vendor_Portal] -->|X_Vendor_Token| API - Lambda[Lambda_ingest] --> DynamoDB[(DynamoDB)] - DynamoDB --> Sync[SyncController] - Sync --> DB[(SQL_Server)] - API --> DB - Blazor[Blazor_EF] -->|legado| DB -``` - ---- - -## 3. Trilhas de entrega (coexistência) - -| Trilha | Escopo Fase 0+ | -|--------|----------------| -| **A — SHOC** | Fases 0–7; feature flags; piloto dispatchers | -| **B — Blazor** | Smoke EF pós-migration; bugfix crítico only | -| **C — Vendor Portal** | Regression checklist cada release WO/Dispatch | -| **D — Lambda/Sync** | Manter até SHOC produção; merge policy Fase 0 | - ---- - -## 4. Endpoints legado vs board - -| Tipo | Exemplos | Política | -|------|----------|----------| -| Legado | GetWorkOrderList, ChangeStatus | Congelar contrato | -| Board (Fase 1+) | GetWorkOrdersBoard | Novos contratos separados | - ---- - -## 5. Gaps Fase 0 endereçados - -| Gap | Mitigação | -|-----|-----------| -| Blazor sem audit | Congelar; não expandir | -| Sync overwrite | ISyncFieldMergePolicy + ManualEditFlag | -| Auth WO desabilitada | Reativar `[Authorize]` WorkOrderController | -| WO# inconsistente | Documentar normalização 11 dígitos (Fase 2 execução) | - ---- - -## 6. Checklist pré-go-live PO - -- [ ] App mobile externo confirmado? -- [ ] Data sunset Blazor WO definida? -- [ ] FrontendBaseUrl produção → SHOC? -- [ ] Owner Lambda cutover nomeado? - ---- - -## 7. Auth — consumidores e tokens - -| Consumidor | Auth | -|------------|------| -| SHOC | JWT Bearer | -| Sync | JWT Bearer (`[Authorize]` no SyncController) | -| Vendor Portal | X-Vendor-Token (rotas públicas dispatch) | -| Blazor | Cookie Identity (app separado) | - -Reativar auth em WorkOrderController exige SHOC enviar JWT em todas as chamadas WO. diff --git a/docs/work-orders/phase-0/dar-domain-architecture-review.md b/docs/work-orders/phase-0/dar-domain-architecture-review.md deleted file mode 100644 index 481a20e..0000000 --- a/docs/work-orders/phase-0/dar-domain-architecture-review.md +++ /dev/null @@ -1,139 +0,0 @@ -# DAR — Domain Architecture Review (Work Orders) - -**Fase:** 0 -**Status:** Draft para sign-off CTO -**Referência:** [roadmap-work-orders-board.md](../../roadmap-work-orders-board.md) §4–5 - ---- - -## 1. Objetivo - -Formalizar o modelo de domínio operacional do Schedule Board antes de qualquer migration de schema board ou contrato de listagem semanal. - ---- - -## 2. Agregados e limites (anti God Entity) - -```mermaid -flowchart TB - subgraph root [AggregateRoot_WorkOrder] - CORE[Core_Lifecycle_Assignment] - SCH[Scheduling_slice] - TRK[Tracking_slice] - COMP[Completion_slice] - ANA[Analytics_slice] - end - subgraph separate [Agregado_separado] - DISP[Dispatch_fonte_vendor] - end - AUD[WorkOrderAuditLog_append_only] - CORE --> SCH - CORE --> TRK - CORE --> ANA - CORE --> COMP - CORE --> DISP - CORE --> AUD -``` - -| Agregado / Slice | Responsabilidade | Persistência Fase 0 | -|------------------|------------------|---------------------| -| **Core** | Id, WoNumber, Type, LifecycleStatus, SiteCode, LocationId, DueDate, AssignTo, Title, RowVersion, PrimaryDispatchId | Colunas em `workOrders` | -| **Scheduling** | ScheduledDate, ScheduledStart/End, TargetWeek, ScheduleWeekOnly | Colunas em `workOrders` | -| **Tracking** | OriginalWeek, OriginalDate (set-once) | Colunas em `workOrders` | -| **Analytics** | RescheduleCount, CarriedOver | Colunas em `workOrders` | -| **Completion** | DocStatus, refs attachments | Coluna DocStatus + attachments existentes | -| **Dispatch** | Vendor, tech, status portal | Tabela `Dispatches` + RowVersion | -| **Audit** | Rastreabilidade imutável | `WorkOrderAuditLogs` | - -**Proibido:** `VendorId`, `TechName`, `TechPhone` canônicos na entidade WorkOrder. Vendor sempre via Dispatch primário. - ---- - -## 3. Fonte da verdade por conceito - -| Conceito | Fonte da verdade | Proibido | -|----------|------------------|----------| -| Status operacional | `LifecycleStatus` (10 valores FE) | String livre `Status` como canônico | -| Past Due | Derivado on-read (`ScheduledDate < hoje` AND NOT terminal) | Misturar no enum lifecycle | -| Vendor / Tech | Dispatch primário (`PrimaryDispatchId`) | Duplicar na WO | -| Schedule | Scheduling slice | Duplicar no core sem slice lógico | -| Completion | `DocStatus` | Inferir só de dispatch signoff | -| Reschedule / CarriedOver | Analytics + domain events | Job blind overwrite | -| WO# | `InternalWONumber` / `WorkerOrderNumber` com regra 11 dígitos (Fase 2+) | Dois números sem regra | -| POC | `WorkOrderContacts` + Notes | Só no detalhe sem contato | - ---- - -## 4. Lifecycle Status vs Operational Flags - -```text -LifecycleStatus → enum único (10 valores alinhados ao frontend) -OperationalFlags → PastDue (derivado on-read; cache opcional Fase 5) -LegacyStatus → string original read-only (migration Fase 0) -``` - -Filtro de status no board = `LifecycleStatus`. Overlay Past Due = flag derivada. Bloqueio de edição = validação sobre flag (Fase 2). - -### Mapeamento legado → LifecycleStatus - -| Legado (`Status`) | LifecycleStatus | -|-------------------|-----------------| -| Open | Incomplete | -| InProgress / In Progress | InProgress | -| Completed / Complete | Complete | -| Cancelled / Canceled | Canceled | -| OnHold / On Hold | OnHold | -| Desconhecido | Incomplete + NeedsReview | - ---- - -## 5. CarriedOver — justificativa - -| | isPastDue | carriedOver | -|--|-----------|-------------| -| Natureza | Estado pontual | Métrica histórica acumulada | -| Persistir | Não (derivado) | Sim (contador) | -| Incremento | N/A | Somente via domain event `WeekRolled` (Fase 5) | - ---- - -## 6. Scheduling Aggregate Growth Watchlist - -Campos que **não** entram em Scheduling sem ARB review: - -- `MoveReason`, `MoveUser`, `MoveCategory`, `MoveSource` - -Metadados de movimentação → Audit Event Contract. - -**Gate:** slice Scheduling > 8 campos operacionais → ARB obrigatório. -Contagem Fase 0: ScheduledDate, ScheduledStart, ScheduledEnd, TargetWeek, ScheduleWeekOnly, OriginalWeek, OriginalDate (tracking separado) — dentro do limite. - ---- - -## 7. Jobs — domínio primeiro - -| Job | Papel | -|-----|-------| -| Past Due diário | Cache refresh opcional; on-read sempre correto | -| Carried Over semanal | Dispara `WeekRolled` → incrementa contador + audit system | -| Promoção Overdue type | Derivado on-read | -| Audit | Síncrono em toda mutação desde Fase 0 | - -**Regra de ouro:** Jobs nunca são a única fonte da verdade. - ---- - -## 8. Critérios de aceite (sign-off CTO) - -- [ ] Zero campos vendor canônicos na WO -- [ ] Agregados documentados e refletidos no schema Fase 0 -- [ ] Persistido vs derivado validado com PO (ver `dar-persisted-vs-derived-matrix.md`) -- [ ] Watchlist Scheduling registrada -- [ ] Blazor congelado — sem novos campos board via EF direto - -**Assinaturas** - -| Papel | Nome | Data | -|-------|------|------| -| CTO / Arquiteto | | | -| Product Owner | | | diff --git a/docs/work-orders/phase-0/dar-persisted-vs-derived-matrix.md b/docs/work-orders/phase-0/dar-persisted-vs-derived-matrix.md deleted file mode 100644 index ceef146..0000000 --- a/docs/work-orders/phase-0/dar-persisted-vs-derived-matrix.md +++ /dev/null @@ -1,54 +0,0 @@ -# DAR — Matriz Persistido vs Derivado - -**Fase:** 0 -**Status:** Draft para validação PO + CTO - ---- - -## Matriz de campos (board + domínio) - -| Campo FE / conceito | Persistido | Derivado | Fonte / Regra | -|---------------------|------------|----------|---------------| -| lifecycleStatus | Sim | Não | Core — `LifecycleStatus` enum | -| isPastDue | Não | **Sim** | `ScheduledDate < UTC hoje` AND NOT status terminal | -| type (Overdue) | Parcial | **Sim** | Regra type + schedule + status | -| workOrderType | Sim | Não | Core — enum `WorkOrderType` | -| carriedOver | Sim | Não | Analytics — incremento via `WeekRolled` only | -| rescheduleCount | Sim | Parcial | Analytics — incremento em mudança ScheduledDate | -| docStatus | Sim | Não | Completion slice | -| dayGroup | Não | Sim | Calculado na leitura a partir de ScheduledDate | -| apptTime | Não | Sim | Projeção Dispatch primário ScheduledDate/Start | -| vendorName / tech | Não | Sim (projeção) | Join Dispatch primário | -| targetWeek | Sim | Não | Scheduling slice | -| scheduledEnd | Sim | Não | Scheduling slice | -| originalWeek / originalDate | Sim | Não | Tracking — set-once na primeira agenda | -| operationalFlags.pastDue | Não* | Sim | *Cache opcional Fase 5; on-read authoritative | -| legacyStatus | Sim (read-only) | Não | Valor string original pré-migration | - ---- - -## Regras de derivação (on-read) - -### isPastDue - -```text -isPastDue = ScheduledDate.HasValue - AND ScheduledDate.Value.Date < DateTime.UtcNow.Date - AND LifecycleStatus NOT IN (Complete, Canceled) -``` - -### type Overdue (derivado) - -WO com type != Overdue pode exibir badge Overdue quando isPastDue && regras de negócio PO confirmadas. - ---- - -## Validação PO - -| Pergunta | Resposta PO | -|----------|-------------| -| Past Due separado do lifecycle status? | Sim | -| carriedOver persiste mesmo após reschedule? | Sim | -| Vendor nunca duplicado na WO? | Sim | - -**Assinatura PO:** _________________ Data: _______ diff --git a/docs/work-orders/phase-0/data-ownership-model.md b/docs/work-orders/phase-0/data-ownership-model.md deleted file mode 100644 index 1c15bbf..0000000 --- a/docs/work-orders/phase-0/data-ownership-model.md +++ /dev/null @@ -1,75 +0,0 @@ -# Data Ownership Model — Work Orders - -**Fase:** 0 -**Status:** Draft para sign-off CTO - ---- - -## 1. Princípios - -1. **SHOC (React)** é o único destino de novas features do board. -2. **SQL Server** é fonte operacional para board, Blazor e Portal. -3. **DynamoDB/Sync** é ponte temporária de ingestão externa. -4. Conflito entre atores resolve-se pela Field Ownership Matrix, não por last-write-wins global. - ---- - -## 2. Ownership por ator - -| Ator | Conexão | Pode alterar | Não pode alterar | -|------|---------|--------------|------------------| -| **SHOC** | REST JWT (`api/WorkOrder`) | Lifecycle, Schedule, Assignment, Completion, vendor via Dispatch, POC, WO# | Checklist/signoff vendor | -| **Vendor Portal** | REST `X-Vendor-Token` | Dispatch status, checklist, signoff, comments vendor | Schedule, lifecycle, assignment, DueDate | -| **Sync/Lambda** | `POST api/Sync/WorkOrders` | Campos ingest (Description, ExternalId, SiteCode na criação, etc.) | Campos SHOC-owned após ManualEditFlag | -| **Blazor** | EF direto (`WorkorderService`) | Campos legados existentes até sunset | Campos novos board; **congelado** | -| **Jobs** | Domain events (Fase 5+) | Incremento CarriedOver; audit System | Lifecycle, Schedule, Dispatch | - ---- - -## 3. Política Blazor (sunset) - -- Manutenção mínima: bugfix crítico only. -- Não recebe: board, inline edit, jobs, advanced search, completion doc WO-level. -- Schema: colunas novas Fase 0 são **nullable** — Blazor continua funcionando sem conhecer novos campos. -- Conflito Blazor vs SHOC: **SHOC vence**; Blazor não deve escrever campos board após go-live Fase 1. -- Smoke test EF obrigatório pós-migration (ver checklist smoke). - ---- - -## 4. Política Vendor Portal - -- Dispatch intacto; zero regressão checklist/signoff/verify. -- Vendor não muta WorkOrder core — apenas Dispatch e comments associados. -- Audit EventType = `Vendor` para mutações portal. - ---- - -## 5. Política Sync/Lambda - -- Upsert por `ExternalWorkOrderId` (idempotente). -- Field-level merge conforme Field Ownership Matrix. -- Campo com ManualEditFlag → skip + audit `SyncRejected`. -- Cutover Lambda → API direta: Fase 7. - ---- - -## 6. Regras de conflito (resumo) - -| Cenário | Vencedor | -|---------|----------| -| SHOC vs SHOC (mesmo campo) | 409 RowVersion; último commit válido | -| SHOC vs Sync (mesmo campo) | SHOC se ManualEditFlag | -| SHOC vs Sync (campos distintos) | Merge | -| Vendor vs Sync | Domínios separados | -| SHOC vs Vendor (mesmo conceito) | Impossível por design (vendor = Dispatch) | -| Blazor vs SHOC | SHOC vence; Blazor congelado | - ---- - -## 7. Critérios de aceite - -- [ ] Matriz por ator revisada por CTO -- [ ] PO confirma política Blazor sunset -- [ ] Sync merge referencia Field Ownership Matrix - -**Assinatura CTO:** _________________ Data: _______ diff --git a/docs/work-orders/phase-0/database-drift-report.md b/docs/work-orders/phase-0/database-drift-report.md deleted file mode 100644 index 8d3ce76..0000000 --- a/docs/work-orders/phase-0/database-drift-report.md +++ /dev/null @@ -1,110 +0,0 @@ -# Database Drift Report — SQL vs EF - -**Fase:** 0 -**Data:** 2026-06-24 -**EF Snapshot:** `Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs` -**Script legado:** `Api.SeaHavenIndustries/db.txt` - ---- - -## 1. Metodologia - -1. Colunas EF extraídas do `ApplicationDbContextModelSnapshot` (entidade `WorkOrder`). -2. Colunas SQL legado extraídas de `db.txt` DDL `[WorkOrders]`. -3. Classificação: **mapear**, **deprecar**, **satélite**, **OK**. - ---- - -## 2. Tabela workOrders — drift conhecido - -| Coluna SQL (legado) | No EF? | Classificação | Ação Fase 0 | -|---------------------|--------|---------------|-------------| -| WorkOrderType | **Não** | mapear | Adicionar `WorkOrderType` int nullable → enum | -| AvettaTask | **Não** | inventariar | Adicionar coluna nullable; uso TBD com PO | -| AssignDate | **Não** | inventariar | Adicionar coluna nullable; possível alias AssignDate tracking | -| Nome tabela WorkOrders vs workOrders | EF usa `workOrders` | OK | Manter EF naming; SQL Server case-insensitive | - ---- - -## 3. Colunas EF (workOrders) — baseline - -Presentes no snapshot e mapeadas: - -InternalWONumber, ExternalWorkOrderId, WorkerOrderNumber, WorkerOrderTitle, Description, Customer, SiteCode, Building, Severity, DateReported, ScheduledStart, ScheduledDate, CompletedDate, Source, SourceEmailS3Key, Problem, Trade, SubTrade, VendorNTE, AssignTo, DueDate, Priority, Status, LocationId, PO, TT, Attachments, BeforPhoto*, AfterPhoto*, SignOff*, istemplate, audit fields (CreatedDate, IsDeleted, etc.) - ---- - -## 4. Novas colunas Fase 0 (migration) - -| Coluna | Tipo | Slice | -|--------|------|-------| -| LifecycleStatus | int nullable | Core | -| LegacyStatus | nvarchar (cópia Status) | Core | -| WorkOrderType | int nullable | Core | -| PrimaryDispatchId | int nullable FK | Core | -| RowVersion | rowversion | Core | -| TargetWeek | date nullable | Scheduling | -| ScheduledEnd | datetime2 nullable | Scheduling | -| ScheduleWeekOnly | bit nullable | Scheduling | -| OriginalWeek | date nullable | Tracking | -| OriginalDate | date nullable | Tracking | -| RescheduleCount | int default 0 | Analytics | -| CarriedOver | int default 0 | Analytics | -| DocStatus | int nullable | Completion | -| AvettaTask | nvarchar max nullable | Legado SQL | -| AssignDate | date nullable | Legado SQL | - ---- - -## 5. Dispatch - -| Item | EF | Ação Fase 0 | -|------|-----|-------------| -| RowVersion | Ausente | Adicionar | -| Demais colunas | OK | Manter | - ---- - -## 6. WorkOrderAuditLog - -| Coluna | EF atual | Ação Fase 0 | -|--------|----------|-------------| -| EventType | Ausente | Adicionar | -| ActorType | Ausente | Adicionar | -| DispatchId | Ausente | Adicionar nullable | -| CorrelationId | Ausente | Adicionar nullable | - ---- - -## 7. WorkOrderContacts - -| Coluna | EF | Ação Fase 0 | -|--------|-----|-------------| -| Notes | Ausente | Adicionar nvarchar nullable (pocNotes) | - ---- - -## 8. ApplicationUser (AspNetUsers) - -| Coluna | Ação Fase 0 | -|--------|-------------| -| Initials | nvarchar(8) nullable | -| Color | nvarchar(16) nullable | - ---- - -## 9. Tabelas revisadas — sem drift crítico adicional - -- `Comments` — OK -- `Locations` — OK (SiteCode via WO.SiteCode) -- `DispatchWorkOrders` — OK - ---- - -## 10. Recomendações - -1. **Eliminar dual-path:** `db.txt` catch-up não deve ser usado após migration EF Phase0; documentar em runbook. -2. **WorkOrderType:** mapear valores SQL existentes → enum na migration data script (dry-run). -3. **AvettaTask / AssignDate:** manter nullable; PO valida uso antes de exposição board. - -**Gate:** Diff assinado por Backend antes de merge migration. diff --git a/docs/work-orders/phase-0/field-ownership-matrix.md b/docs/work-orders/phase-0/field-ownership-matrix.md deleted file mode 100644 index 7bfe7f7..0000000 --- a/docs/work-orders/phase-0/field-ownership-matrix.md +++ /dev/null @@ -1,86 +0,0 @@ -# Field Ownership Matrix — Work Orders - -**Fase:** 0 -**Depende de:** [data-ownership-model.md](data-ownership-model.md) -**Status:** Draft para sign-off CTO + PO - ---- - -## Legenda - -- **Owner escritor:** ator autorizado a criar/alterar o valor canônico. -- **Sync sobrescreve?:** se ingest DynamoDB pode substituir após WO existir. -- **ManualEditFlag:** primeira edição SHOC bloqueia Sync no campo (ver [manual-edit-flag-design.md](manual-edit-flag-design.md)). - ---- - -## Matriz completa - -| Campo | Agregado | Owner escritor | Sync sobrescreve? | Conflito SHOC vs Sync | -|-------|----------|----------------|-------------------|----------------------| -| LifecycleStatus | Core | SHOC | Não após 1ª edição SHOC | SHOC vence | -| LegacyStatus | Core | — (read-only) | Não | N/A | -| AssignTo | Core | SHOC | Não após ManualEditFlag | SHOC vence | -| ScheduledDate | Scheduling | SHOC | Não | SHOC vence | -| ScheduledStart | Scheduling | SHOC | Parcial (ingest inicial) | SHOC após flag | -| ScheduledEnd | Scheduling | SHOC | Não | SHOC vence | -| TargetWeek | Scheduling | SHOC | Não | SHOC vence | -| ScheduleWeekOnly | Scheduling | SHOC | Não | SHOC vence | -| DueDate | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag | -| Description | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag | -| WorkerOrderTitle | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag | -| SiteCode | Core | SHOC + Sync | Sim na criação; não após SHOC | SHOC após flag | -| Building | Core | SHOC + Sync | Sim na criação | SHOC após flag | -| LocationId | Core | SHOC + Sync | Sim na criação | SHOC após flag | -| WorkOrderType | Core | SHOC + Sync | Sim na criação ingest | SHOC após flag | -| InternalWONumber / WoNumber | Core | SHOC (Sync só na criação) | Não | SHOC only | -| ExternalWorkOrderId | Core | Sync | Sim (idempotente key) | Sync only | -| VendorId / VendorName | Dispatch | SHOC + Vendor | Não | Domínios separados | -| Dispatch Status | Dispatch | Vendor + SHOC | Não | Por contexto | -| Checklist / Signoff | Dispatch | Vendor | Não | Vendor vence | -| DocStatus | Completion | SHOC | Não | SHOC only | -| RescheduleCount | Analytics | Domain events | Não | N/A | -| CarriedOver | Analytics | Jobs (WeekRolled) | Não | N/A | -| OriginalWeek / OriginalDate | Tracking | SHOC (set-once) | Não | SHOC vence | -| POC / WorkOrderContacts.Notes | Contacts | SHOC | Não | SHOC vence | -| Priority / Severity | Core | SHOC + Sync | Sim se nunca editado | SHOC após flag | -| Status (legado string) | Core | — (deprecated write) | Não | Migrar para LifecycleStatus | -| Trade / Problem / SubTrade | Core | SHOC | Parcial Sync ingest | SHOC após flag | -| Customer / Source | Core | Sync + SHOC | Sim na criação | SHOC após flag | -| Attachments / Photos | Core | SHOC + Vendor | Não overwrite mútuo | Por domínio | - ---- - -## Campos board (14 colunas) — ownership resumido - -| Coluna board | Fonte | Owner | -|--------------|-------|-------| -| WO# | Core.InternalWONumber | SHOC | -| Type | Core.WorkOrderType | SHOC | -| Site | Core.SiteCode | SHOC | -| Status | Core.LifecycleStatus | SHOC | -| Assignee | Core.AssignTo | SHOC | -| Due | Core.DueDate | SHOC + Sync | -| Scheduled | Scheduling.ScheduledDate | SHOC | -| Vendor | Dispatch (primário) | SHOC via Dispatch | -| Appt | Dispatch.ScheduledDate | Dispatch | -| Past Due | Derivado | — | -| Carried | Analytics.CarriedOver | Jobs | -| Reschedule | Analytics.RescheduleCount | Domain | -| Doc | Completion.DocStatus | SHOC | -| Actions | — | SHOC UI | - ---- - -## Critérios de aceite - -- [ ] PO valida campos board vs matriz -- [ ] Sync merge implementado referencia esta matriz -- [ ] ManualEditFlag design aprovado - -**Assinaturas** - -| Papel | Data | -|-------|------| -| CTO | | -| PO | | diff --git a/docs/work-orders/phase-0/manual-edit-flag-design.md b/docs/work-orders/phase-0/manual-edit-flag-design.md deleted file mode 100644 index 548727f..0000000 --- a/docs/work-orders/phase-0/manual-edit-flag-design.md +++ /dev/null @@ -1,87 +0,0 @@ -# ManualEditFlag — Design - -**Fase:** 0 -**Implementação:** tabela satélite `WorkOrderFieldLocks` - ---- - -## 1. Problema - -Sync/Lambda faz upsert overwrite em campos que dispatchers editam no SHOC. Precisamos saber, por campo, se houve edição manual SHOC para rejeitar ingest conflitante. - ---- - -## 2. Solução escolhida: tabela satélite - -```text -WorkOrderFieldLocks - Id (PK) - WorkOrderId (FK) - FieldName (string, max 64) - LockedAt (UTC) - LockedByUserId (nullable — null = system/bootstrap) - UNIQUE (WorkOrderId, FieldName) -``` - -**Alternativa descartada:** bitmask — difícil de auditar e estender. - ---- - -## 3. Comportamento - -| Evento | Ação | -|--------|------| -| SHOC edita campo X pela 1ª vez | INSERT lock (WorkOrderId, FieldName) | -| Sync tenta atualizar campo X com lock | Skip update; audit `SyncRejected` | -| Sync atualiza campo Y sem lock | Apply merge normal | -| WO novo (Sync create) | Sem locks; full ingest | -| Blazor edita campo legado | **Não** cria lock Fase 0 (gap conhecido; Blazor congelado) | - ---- - -## 4. Campos elegíveis a lock (SHOC writers) - -Definidos em `SyncFieldMergePolicy.ShocOwnedFields`: - -- LifecycleStatus, AssignTo, ScheduledDate, ScheduledEnd, TargetWeek, ScheduleWeekOnly -- DueDate, Description, WorkerOrderTitle, SiteCode, Building, LocationId -- WorkOrderType, DocStatus, Trade, Problem, Priority, InternalWONumber - ---- - -## 5. Audit em rejeição - -```json -{ - "eventType": "Sync", - "action": "SyncRejected", - "fieldName": "DueDate", - "oldValue": "2026-06-01", - "newValue": "2026-06-15", - "actorType": "Sync" -} -``` - ---- - -## 6. API interna - -```csharp -interface IWorkOrderFieldLockService -{ - Task LockFieldAsync(int workOrderId, string fieldName, string? userId); - Task IsLockedAsync(int workOrderId, string fieldName); -} -``` - -Implementação: `WorkOrderFieldLockService` em `SeaHaven.Services`. - -Lock criado automaticamente por `IWorkOrderAuditService` em eventos `Manual` + `FieldChanged` / `StatusChanged`. - ---- - -## 7. Critérios de aceite - -- [ ] Tabela criada na migration Phase0 -- [ ] Sync consulta locks antes de overwrite -- [ ] SyncRejected aparece no audit log diff --git a/docs/work-orders/phase-0/migration-dry-run-report.md b/docs/work-orders/phase-0/migration-dry-run-report.md deleted file mode 100644 index 2a815e0..0000000 --- a/docs/work-orders/phase-0/migration-dry-run-report.md +++ /dev/null @@ -1,58 +0,0 @@ -# Migration Dry-Run Report — Phase 0 - -**Migration:** `20260624163145_Phase0_DomainFoundation` -**Status:** Pronto para execução em staging - ---- - -## 1. Procedimento - -1. Restore backup produção → staging espelho -2. Snapshot pré-migration: - ```sql - SELECT Status, COUNT(*) FROM workOrders GROUP BY Status; - SELECT COUNT(*) AS Total FROM workOrders; - ``` -3. Executar: - ```powershell - dotnet ef database update --project Data.SeaHavenIndustries --startup-project Api.SeaHavenIndustries - ``` -4. Validar pós-migration: - ```sql - SELECT COUNT(*) FROM workOrders WHERE LegacyStatus IS NOT NULL; - SELECT LifecycleStatus, COUNT(*) FROM workOrders GROUP BY LifecycleStatus; - SELECT COUNT(*) FROM workOrders WHERE PrimaryDispatchId IS NOT NULL; - ``` -5. Smoke Blazor + API legado + Sync sample -6. Rollback (se falha): - ```powershell - dotnet ef database update 20260417195316_AddDispatchVerification --project Data.SeaHavenIndustries --startup-project Api.SeaHavenIndustries - ``` - ---- - -## 2. Data scripts incluídos na migration - -- `LegacyStatus` ← cópia de `Status` -- `LifecycleStatus` ← mapeamento legado (Open→Incomplete, etc.) -- `PrimaryDispatchId` ← dispatch mais recente por WO - ---- - -## 3. Resultados (preencher em staging) - -| Métrica | Antes | Depois | -|---------|-------|--------| -| Total WOs | | | -| Com LegacyStatus | | | -| Com LifecycleStatus | | | -| Com PrimaryDispatchId | | | -| Rollback testado | | Sim/Não | - ---- - -## 4. Notas - -A migration inclui reconciliação de drift em `Locations`, `Regions`, `Departments` detectada pelo EF — revisar impacto em staging antes de produção. - -**WO# normalização 11 dígitos:** documentada para Fase 2; não executada nesta migration. diff --git a/docs/work-orders/phase-0/phase-0-gates-signoff.md b/docs/work-orders/phase-0/phase-0-gates-signoff.md deleted file mode 100644 index ddc4199..0000000 --- a/docs/work-orders/phase-0/phase-0-gates-signoff.md +++ /dev/null @@ -1,46 +0,0 @@ -# Phase 0 — Gates Sign-Off Checklist - -**Programa:** Work Orders Board -**Fase:** 0 — Domain Foundation + Governança - ---- - -## 9 Gates bloqueantes - -| # | Gate | Artefato | Status | -|---|------|----------|--------| -| 1 | DAR aprovado | [dar-domain-architecture-review.md](dar-domain-architecture-review.md) | Documentado | -| 2 | Data Ownership Model | [data-ownership-model.md](data-ownership-model.md) | Documentado | -| 3 | Field Ownership Matrix | [field-ownership-matrix.md](field-ownership-matrix.md) | Documentado | -| 4 | Audit Event Contract | [audit-event-contract.md](audit-event-contract.md) | Implementado baseline | -| 5 | Volume Discovery | [volume-discovery-report.md](volume-discovery-report.md) | Queries prontas; tier S provisório | -| 6 | Database drift | [database-drift-report.md](database-drift-report.md) | Documentado | -| 7 | Spike vendor/dispatch | [adr-vendor-source-of-truth.md](adr-vendor-source-of-truth.md) | ADR + query | -| 8 | Migration dry-run | [migration-dry-run-report.md](migration-dry-run-report.md) | Procedimento pronto | -| 9 | RowVersion multi-agregado | WO + Dispatch + ConcurrencyExceptionFilter | Implementado | - ---- - -## Implementação código (Fase 0) - -- Migration `Phase0_DomainFoundation` -- Enums: LifecycleStatus, WorkOrderType, DocStatus, AuditEventType, etc. -- `IWorkOrderAuditService`, `IWorkOrderFieldLockService`, `ISyncFieldMergePolicy` -- `[Authorize]` reativado em WorkOrderController -- Projeto `SeaHavenIndustries.Tests` - ---- - -## GO Fase 1 — pendente - -- [ ] Sign-off CTO/PO nos artefatos -- [ ] Dry-run staging executado -- [ ] Volume tier confirmado em staging/prod -- [ ] Smoke checklist verde - -**Assinatura GO Fase 1** - -| Papel | Nome | Data | -|-------|------|------| -| CTO | | | -| PO | | | diff --git a/docs/work-orders/phase-0/rowversion-design.md b/docs/work-orders/phase-0/rowversion-design.md deleted file mode 100644 index 9e80ddc..0000000 --- a/docs/work-orders/phase-0/rowversion-design.md +++ /dev/null @@ -1,36 +0,0 @@ -# RowVersion Multi-Agregado — Design - -**Fase:** 0 — Gate #9 - ---- - -## Agregados - -| Agregado | Coluna | Comportamento | -|----------|--------|---------------| -| WorkOrder | `RowVersion` rowversion | Bump em mutação WO (schedule, status, assignment) | -| Dispatch | `RowVersion` rowversion | Bump em mutação vendor/checklist/signoff | - ---- - -## HTTP 409 - -`ConcurrencyExceptionFilter` captura `DbUpdateConcurrencyException` e retorna: - -```json -{ - "status": "Conflict", - "message": "The record was modified by another user. Refresh and retry.", - "code": 409 -} -``` - ---- - -## Regras (Fase 2 testes completos) - -- SHOC edita ScheduledDate → bump WO.RowVersion -- SHOC edita vendor → bump Dispatch.RowVersion -- Vendor edita checklist → bump Dispatch.RowVersion only - -Testes de concorrência paralela: Fase 2 (`Concurrency integration tests` backlog #27). diff --git a/docs/work-orders/phase-0/smoke-checklist.md b/docs/work-orders/phase-0/smoke-checklist.md deleted file mode 100644 index 0bad17e..0000000 --- a/docs/work-orders/phase-0/smoke-checklist.md +++ /dev/null @@ -1,57 +0,0 @@ -# Smoke Test Checklist — Multi-Consumidor (Fase 0) - -**Ambiente:** Docker local (`docker compose up -d` + `scripts/setup-local-db.ps1`) - ---- - -## Blazor EF - -| # | Teste | Pass | -|---|-------|------| -| 1 | `/workorderlist` carrega sem exception | [ ] | -| 2 | Abrir detalhe WO existente | [ ] | -| 3 | Campos novos nullable não quebram binding | [ ] | - ---- - -## API REST (legado) - -| # | Teste | Pass | -|---|-------|------| -| 1 | POST login → JWT | [ ] | -| 2 | GET work order list com Bearer token | [ ] | -| 3 | POST ChangeStatus com audit no log | [ ] | -| 4 | POST ChangeAssignment.tex Assignment com audit | [ ] | -| 5 | 401 sem token (auth reativada) | [ ] | - ---- - -## Sync - -| # | Teste | Pass | -|---|-------|------| -| 1 | POST `api/Sync/WorkOrders` com JWT (mock/staging DynamoDB) | [ ] | -| 2 | Upsert idempotente por ExternalWorkOrderId | [ ] | -| 3 | Campo locked → SyncRejected no audit | [ ] | - ---- - -## Vendor Portal - -| # | Teste | Pass | -|---|-------|------| -| 1 | Checklist update flow | [ ] | -| 2 | Signoff flow | [ ] | -| 3 | Dispatch RowVersion presente (schema) | [ ] | - ---- - -## Automação local - -```powershell -dotnet test SeaHavenIndustries.Tests -dotnet build -./scripts/setup-local-db.ps1 -``` - -Testes unitários cobrem: LifecycleStatusMapper, audit baseline, sync merge rejection. diff --git a/docs/work-orders/phase-0/volume-discovery-report.md b/docs/work-orders/phase-0/volume-discovery-report.md deleted file mode 100644 index 12c48c3..0000000 --- a/docs/work-orders/phase-0/volume-discovery-report.md +++ /dev/null @@ -1,96 +0,0 @@ -# Volume Discovery Report — Work Orders - -**Fase:** 0 — Gate Fase 1 -**Data:** 2026-06-24 -**Ambiente:** Local dev (Docker) + queries para staging/prod - ---- - -## 1. Objetivo - -Definir tier S/M/L/XL antes de índices e estratégia de search (Fase 4). **Sem premissa de volume.** - ---- - -## 2. Queries obrigatórias - -Executar em staging ou produção read-only: - -```sql --- Total WOs -SELECT COUNT(*) AS TotalWorkOrders -FROM workOrders -WHERE IsDeleted IS NULL OR IsDeleted = 0; - --- WOs por semana (ISO) — p95 -WITH WeeklyCounts AS ( - SELECT DATEPART(iso_week, ScheduledDate) AS IsoWeek, - YEAR(ScheduledDate) AS IsoYear, - COUNT(*) AS Cnt - FROM workOrders - WHERE ScheduledDate IS NOT NULL - AND (IsDeleted IS NULL OR IsDeleted = 0) - GROUP BY DATEPART(iso_week, ScheduledDate), YEAR(ScheduledDate) -) -SELECT MAX(Cnt) AS P95ProxyMaxPerWeek, - AVG(Cnt * 1.0) AS AvgPerWeek -FROM WeeklyCounts; - --- Unscheduled (não terminal) -SELECT COUNT(*) AS UnscheduledCount -FROM workOrders -WHERE ScheduledDate IS NULL - AND (IsDeleted IS NULL OR IsDeleted = 0) - AND Status NOT IN ('Completed', 'Complete', 'Cancelled', 'Canceled'); - --- Crescimento mensal -SELECT YEAR(CreatedDate) AS Y, MONTH(CreatedDate) AS M, COUNT(*) AS Cnt -FROM workOrders -WHERE CreatedDate IS NOT NULL -GROUP BY YEAR(CreatedDate), MONTH(CreatedDate) -ORDER BY Y DESC, M DESC; -``` - ---- - -## 3. Resultados - -| Métrica | Local Docker (dev) | Staging | Produção | -|---------|-------------------|---------|----------| -| Total WOs | *executar após seed* | _pendente acesso_ | _pendente acesso_ | -| WOs/semana p95 | — | _pendente_ | _pendente_ | -| Unscheduled | — | _pendente_ | _pendente_ | -| Crescimento mensal | — | _pendente_ | _pendente_ | - -**Nota:** Ambiente local inicia vazio. Tier provisório para desenvolvimento: **S** (< 25k). Confirmar com query em staging antes do GO Fase 1. - ---- - -## 4. Classificação tier (§10.2 roadmap) - -| Tier | Total WOs | Listagem semanal | Advanced search | -|------|-----------|------------------|-----------------| -| **S** | < 25k | Índices simples | SQL filtros compostos | -| **M** | 25k–250k | Índices covering | Paginação obrigatória | -| **L** | 250k–1M | Read model candidato | Full-text | -| **XL** | > 1M | Materialized view | Search dedicado (ARB) | - -**Tier definido para Fase 1:** **S (provisório dev)** — revisar ao obter métricas staging. - ---- - -## 5. Impacto Fase 0–1 - -- Fases 1–3: índices conservadores compatíveis com qualquer tier. -- Fase 4: implementação search conforme tier confirmado + load test. - ---- - -## 6. Script local - -```powershell -./scripts/setup-local-db.ps1 -# Executar queries acima via sqlcmd ou DBCode contra localhost:1433 -``` - -**Gate:** Preencher coluna Staging/Produção antes do GO Fase 1. diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md deleted file mode 100644 index 5442c49..0000000 --- a/docs/work-orders/phase-1/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Fase 1 — Weekly Board (leitura) - -**Programa:** Work Orders Board -**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais. - ---- - -## Endpoints - -### `GET /api/workorders/board` - -Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada. - -**Autenticação:** Bearer JWT (`[Authorize]`) - -**Query params:** - -| Param | Obrigatório | Descrição | -|-------|-------------|-----------| -| `weekStart` | Sim | Segunda-feira da semana (`YYYY-MM-DD`) | -| `weekEnd` | Não | Sexta-feira (default: `weekStart + 4 dias`) | -| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos | -| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` | -| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) | -| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) | - -**Exemplo:** - -```bash -curl -H "Authorization: Bearer " \ - "https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true" -``` - -**Response:** - -```json -{ - "weekStart": "2026-06-22", - "weekEnd": "2026-06-26", - "counts": { "returned": 42, "total": 58 }, - "unscheduled": [ /* WorkOrderBoardRowDto[] */ ], - "scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ] -} -``` - -- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search) -- `counts.returned` — WOs agendadas após aplicar search - -### `GET /api/workorders/lookups/dispatchers` - -Lista dispatchers para filtros do board. - -```json -[ - { "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" } -] -``` - ---- - -## Matriz coluna → campo API - -| Coluna board | Campos response | -|--------------|-----------------| -| WO# | `woNumber`, `rescheduleCount`, `carriedOver` | -| Type | `workOrderType`, `isPastDue` | -| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` | -| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` | -| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` | -| Due | `dueDate` | -| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` | -| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` | -| Appt | `apptDate`, `apptTime` | -| Past Due | `isPastDue` (derivado on-read) | -| Carried | `carriedOver` | -| Reschedule | `rescheduleCount` | -| Doc | `docStatus` | -| Actions | FE only | - ---- - -## Regras de derivação - -### isPastDue - -``` -ScheduledDate < UTC hoje -AND LifecycleStatus NOT IN (Complete, Canceled, Closed) -``` - -Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` - -### Vendor / Appt - -Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)). - -### Janela semanal - -WO entra em **scheduled** se: - -- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou** -- `ScheduleWeekOnly == true` e `TargetWeek == weekStart` - -**Unscheduled:** `ScheduledDate == null` e status não terminal. - ---- - -## Código entregue - -| Camada | Arquivo | -|--------|---------| -| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | -| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` | -| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` | -| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` | -| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) | -| Migration | `Data.SeaHavenIndustries/Migrations/20260624180343_Phase1_BoardIndexes.cs` | -| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` | - ---- - -## Gates de aceite - -### Desenvolvimento (concluído) - -- [x] Endpoint `GET board` — semana + Unscheduled -- [x] Projeção 12/14 colunas de dados -- [x] `isPastDue` derivado on-read -- [x] Vendor via dispatch primário -- [x] Filtros dispatcher / My WOs / tipo / search -- [x] Contador X of Y -- [x] Índices Tier S -- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total) - -### Staging / produção (pendente) - -- [ ] Sign-off CTO/PO (herda gates Fase 0) -- [ ] Dry-run migration Phase0 + Phase1 em staging -- [ ] Tier volume confirmado em staging/prod -- [ ] Smoke checklist Blazor / Sync / Portal -- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados) -- [ ] Latência aceitável com volume real (Tier S: < 25k WOs) -- [ ] Feature flag SHOC board em staging - ---- - -## Coexistência - -Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas. - ---- - -## Próximo passo - -**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular). diff --git a/docs/work-orders/phase-2/README.md b/docs/work-orders/phase-2/README.md deleted file mode 100644 index 584e8ad..0000000 --- a/docs/work-orders/phase-2/README.md +++ /dev/null @@ -1,154 +0,0 @@ -# Fase 2 — Inline Edit + Concorrência - -**Programa:** Work Orders Board -**Objetivo:** Edição spreadsheet-style célula a célula no board SHOC, com dual RowVersion, audit granular e regras de domínio no backend. - -**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md) - ---- - -## Endpoints - -### `PATCH /api/workorders/{id}/board` - -Atualiza um único campo do board com validação otimista de concorrência. - -**Autenticação:** Bearer JWT (`[Authorize]`) - -**Request body:** - -```json -{ - "field": "scheduledDate", - "value": "2026-06-25", - "workOrderVersion": "", - "dispatchVersion": "", - "primaryDispatchId": 123 -} -``` - -| Campo | Obrigatório | Descrição | -|-------|-------------|-----------| -| `field` | Sim | Nome canônico do campo (case-insensitive) | -| `value` | Sim* | Valor serializado como string (*pode ser vazio para limpar datas) | -| `workOrderVersion` | Sim | `RowVersion` atual da WO (Base64) | -| `dispatchVersion` | Condicional | Obrigatório para campos Dispatch quando dispatch já existe | -| `primaryDispatchId` | Não | Valida que o dispatch pertence à WO | - -**Responses:** - -| HTTP | Body | Quando | -|------|------|--------| -| 200 | `WorkOrderBoardRowDto` | Sucesso — row completo com versões atualizadas | -| 409 | `WorkOrderBoardConflictDto` + `currentState` | Conflito de versão | -| 422 | `WorkOrderBoardValidationErrorDto` | Regra de domínio violada | -| 400 | `Response` | Erro genérico / argumento inválido | - -**Exemplo:** - -```bash -curl -X PATCH -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"field":"siteCode","value":"BK5","workOrderVersion":"AQAAAAAAAAA="}' \ - "https://localhost:5001/api/workorders/1/board" -``` - ---- - -## Campos editáveis - -| field (API) | Agregado | Coluna board | Audit FieldName | -|-------------|----------|--------------|-----------------| -| `woNumber` | WorkOrder | WO# | InternalWONumber | -| `workOrderType` | WorkOrder | Type | WorkOrderType | -| `siteCode` | WorkOrder | Site | SiteCode | -| `lifecycleStatus` | WorkOrder | Status | LifecycleStatus | -| `assignTo` | WorkOrder | Assignee | AssignTo | -| `dueDate` | WorkOrder | Due | DueDate | -| `scheduledDate` | WorkOrder | Scheduled | ScheduledDate | -| `targetWeek` | WorkOrder | Scheduled (week-only) | TargetWeek | -| `scheduleWeekOnly` | WorkOrder | Scheduled | ScheduleWeekOnly | -| `vendorId` | Dispatch | Vendor | VendorId | -| `apptDate` | Dispatch | Appt | ApptDate | -| `apptTime` | Dispatch/WO | Appt | ApptTime | -| `docStatus` | WorkOrder | Doc | DocStatus | - -**Read-only:** `isPastDue`, `carriedOver`, `rescheduleCount` (badge derivado; count incrementado por regra). - ---- - -## Dual RowVersion - -| Agregado | Quando exigir versão | -|----------|---------------------| -| WorkOrder | Sempre (`workOrderVersion`) | -| Dispatch | Campos vendor/appt quando dispatch primário já existe (`dispatchVersion`) | - -O GET board retorna `rowVersion`, `dispatchRowVersion` e `primaryDispatchId` em cada row. - -**409 currentState:** inclui row recarregado do DB para refresh imediato no FE. - ---- - -## Regras de domínio - -| Regra | Comportamento | -|-------|---------------| -| Auto-schedule | `Incomplete` + `scheduledDate` + `assignTo` → `Scheduled` | -| Reschedule | Mudança de `scheduledDate` com data anterior → `rescheduleCount++` | -| Set-once tracking | Primeira `scheduledDate` → `OriginalDate` / `OriginalWeek` | -| Past Due block | Não permite mudar `lifecycleStatus` quando `isPastDue` | -| Cancel read-only | Status terminal (`Canceled`, `Closed`, `Complete`) bloqueia PATCH | -| WO# 11 dígitos | Normalização numérica + unicidade | -| Vendor ADR | Mutação em `Dispatch`; cria dispatch primário se ausente | - ---- - -## Audit - -- 1 evento por campo alterado (`FieldChanged`, `StatusChanged`, ou `AssignmentChanged`) -- Side effects (auto-schedule, rescheduleCount) geram eventos adicionais -- Field lock (`WorkOrderFieldLocks`) criado automaticamente em edições manuais -- Eventos Dispatch incluem `dispatchId` - ---- - -## Código entregue - -| Camada | Arquivo | -|--------|---------| -| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | -| Helpers | `WorkOrderBoardMutationRules`, `WorkOrderNumberNormalizer`, `WorkOrderBoardFieldNames`, `WorkOrderBoardApptTimeParser` | -| Update service | `SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs` | -| Audit | `IWorkOrderAuditService.StageFieldChanged`, `LogFieldChangedAsync` | -| API | `WorkOrderController` — `PATCH {id}/board` | -| Testes | `WorkOrderBoardMutationRulesTests`, `WorkOrderNumberNormalizerTests`, `WorkOrderBoardUpdateServiceTests`, `WorkOrderBoardConcurrencyTests` | - ---- - -## Gates de aceite - -### Desenvolvimento - -- [x] `PATCH /api/workorders/{id}/board` para todos os campos da matriz -- [x] Dual RowVersion validado; 409 com `currentState` -- [x] Auto-schedule Incomplete→Scheduled -- [x] RescheduleCount++ em reagendamento -- [x] Bloqueio status quando PastDue; reagendar limpa flag on-read -- [x] WO cancelado/closed rejeita edição (422) -- [x] WO# normalizado 11 dígitos + unicidade -- [x] Vendor/appt muta Dispatch; cria primário se ausente -- [x] 1 audit event por campo; field lock criado -- [x] 51 testes unitários total (25 Fase 2 + 26 Fases 0–1) - -### Staging (pendente) - -- [ ] Smoke Sync durante edição SHOC -- [ ] UAT dispatcher: editar células reais no board -- [ ] Latência PATCH aceitável (<200ms p95 Tier S) - ---- - -## Próximo passo - -**Fase 3** — Criação wizard/inline, soft cancel dedicado, ManualEditFlag na criação. diff --git a/docs/work-orders/phase-3/README.md b/docs/work-orders/phase-3/README.md deleted file mode 100644 index 1e09aed..0000000 --- a/docs/work-orders/phase-3/README.md +++ /dev/null @@ -1,174 +0,0 @@ -# Fase 3 — Criação e Cancelamento - -**Programa:** Work Orders Board -**Objetivo:** Criação wizard/inline via contrato do board SHOC, cancelamento soft dedicado, ManualEditFlag na criação, e delete físico restrito a Admin. - -**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md) - ---- - -## Endpoints - -### `POST /api/workorders/board` - -Cria uma WO com contrato alinhado ao board. Suporta wizard (payload completo) e inline row (subset mínimo). - -**Autenticação:** Bearer JWT (`[Authorize]`) - -**Request body:** - -```json -{ - "woNumber": "12345", - "workOrderType": "PM", - "siteCode": "BK5", - "assignTo": "dispatcher-guid", - "dueDate": "2026-07-01", - "scheduledDate": "2026-06-25", - "targetWeek": "2026-06-22", - "scheduleWeekOnly": false, - "vendorId": 5, - "apptDate": "2026-06-26", - "apptTime": "09:00 – 11:00", - "docStatus": "No", - "description": "Leak in break room", - "trade": "HVAC PM", - "locationId": 12, - "pocContactId": 3, - "pocNotes": "Call before arrival" -} -``` - -| Campo | Obrigatório | Descrição | -|-------|-------------|-----------| -| `workOrderType` | Sim | Enum `WorkOrderType` | -| `siteCode` | Sim | Código do site | -| `woNumber` | Não | Se omitido, auto-gera sequencial normalizado 11 dígitos | -| `scheduleWeekOnly` | Não | Se `true`, `targetWeek` é obrigatório | -| `vendorId` | Condicional | Obrigatório quando `apptDate` ou `apptTime` informados | - -**Responses:** - -| HTTP | Body | Quando | -|------|------|--------| -| 200 | `WorkOrderBoardRowDto` | Sucesso — row pronto para inserir no board | -| 400 | `Response` | Validação FluentValidation | -| 409 | `WorkOrderBoardValidationErrorDto` | WO# duplicado | -| 422 | `WorkOrderBoardValidationErrorDto` | Regra de domínio | - -**Exemplo (inline mínimo):** - -```bash -curl -X POST -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"workOrderType":"PM","siteCode":"BK5"}' \ - "https://localhost:5001/api/workorders/board" -``` - ---- - -### `POST /api/workorders/{id}/cancel` - -Soft cancel — transição para `LifecycleStatus.Canceled` com WO read-only. - -**Autenticação:** Bearer JWT - -**Responses:** - -| HTTP | Body | Quando | -|------|------|--------| -| 200 | `WorkOrderBoardRowDto` | Cancelado (ou já estava cancelado — idempotente) | -| 422 | `WorkOrderBoardValidationErrorDto` | WO `Complete` ou `Closed` | - -**Exemplo:** - -```bash -curl -X POST -H "Authorization: Bearer " \ - "https://localhost:5001/api/workorders/42/cancel" -``` - ---- - -## Regras de domínio na criação - -| Regra | Comportamento | -|-------|---------------| -| Status inicial | `Incomplete` | -| Auto-schedule | `scheduledDate` + `assignTo` → `Scheduled` | -| Set-once tracking | Primeira `scheduledDate` → `OriginalDate` / `OriginalWeek` | -| Week-only | `scheduleWeekOnly=true` + `targetWeek` → aparece na semana no GET board | -| WO# | Manual normalizado 11 dígitos; auto-gerado se omitido | -| Vendor | Cria dispatch primário quando `vendorId` informado | -| ManualEditFlag | Field locks criados para cada campo SHOC preenchido | - ---- - -## Soft cancel vs hard delete - -| Operação | Endpoint | Quem | Efeito | -|----------|----------|------|--------| -| Soft cancel | `POST /{id}/cancel` | Dispatcher | Status `Canceled`, WO permanece, PATCH bloqueado (422) | -| Hard delete | `DELETE DeleteWorkorder` | **Admin only** | Remove registro, anexos e contatos | - -O legado `POST AddWorkorder` (form/Blazor) permanece inalterado. - ---- - -## ManualEditFlag na criação - -Campos preenchidos na criação SHOC recebem lock em `WorkOrderFieldLocks` via audit `FieldChanged`. Sync subsequente em campo lockado gera `SyncRejected` (ver [manual-edit-flag-design.md](../phase-0/manual-edit-flag-design.md)). - -WO criada via Sync/Lambda continua sem locks até edição SHOC. - ---- - -## Audit - -| Evento | Action | -|--------|--------| -| Criação | 1× `Create` + `FieldChanged` por campo preenchido | -| Auto-schedule na criação | `StatusChanged` adicional | -| Cancel | `StatusChanged` → `Canceled` | -| Hard delete Admin | `Delete` | - ---- - -## Código entregue - -| Camada | Arquivo | -|--------|---------| -| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | -| Validação | `SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs` | -| Scheduling compartilhado | `SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs` | -| Create service | `SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs` | -| Cancel service | `SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs` | -| Audit | `IWorkOrderAuditService.StageCreated`, `LogCreatedAsync` | -| API | `WorkOrderController` — `POST board`, `POST {id}/cancel`, `DELETE` Admin | -| Testes | `WorkOrderBoardCreateServiceTests`, `WorkOrderBoardCancelServiceTests`, `WorkOrderBoardCreateSyncLockTests` | - ---- - -## Gates de aceite - -### Desenvolvimento - -- [x] `POST /api/workorders/board` — Incomplete / auto-schedule / week-only -- [x] WO# auto ou manual 11 dígitos + unicidade (409) -- [x] Vendor/dispatch primário na criação -- [x] Field locks + SyncRejected pós-create SHOC -- [x] `POST /{id}/cancel` idempotente; PATCH bloqueado após cancel -- [x] `DELETE DeleteWorkorder` restrito a Admin -- [x] Legado `AddWorkorder` inalterado -- [x] 68 testes unitários total (17 Fase 3 + 51 Fases 0–2) - -### Staging (pendente) - -- [ ] UAT dispatcher: wizard + inline row -- [ ] Smoke Sync durante criação SHOC -- [ ] Confirmar navegação FE para semana do WO criado - ---- - -## Próximo passo - -**Fase 4** — Busca contextual na semana + advanced search cross-week conforme tier volume. diff --git a/docs/work-orders/phase-4/README.md b/docs/work-orders/phase-4/README.md deleted file mode 100644 index d9b602f..0000000 --- a/docs/work-orders/phase-4/README.md +++ /dev/null @@ -1,162 +0,0 @@ -# Fase 4 — Search contextual + Advanced Search - -**Programa:** Work Orders Board -**Objetivo:** Hardening da busca contextual no board semanal, endpoint de advanced search cross-week (Tier S), índices de suporte e harness de load test. - -**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md), [Fase 3](../phase-3/README.md) - -**Tier vigente (dev):** **S (provisório)** — ver [search-tier-decision.md](./search-tier-decision.md) - ---- - -## Endpoints - -### `GET /api/workorders/board?search=` (hardening) - -Busca contextual na janela semanal. Campos cobertos: - -| Campo | Origem | -|-------|--------| -| Site | `SiteCode` | -| WO# | `InternalWONumber`, `WorkerOrderNumber` | -| Location | `Locations.Name` | -| Dispatcher | `AssignToUser` nome | -| PM | `Trade`, `Problem` | -| Vendor | `PrimaryDispatch.Vendor.CompanyName` | -| Tech | `PrimaryDispatch.Vendor.ContactName` | -| Status legado | `Status` | -| Lifecycle | label enum (`Scheduled`, `In Progress`, etc.) | - -**Regras:** - -- `search` com trim; ignorado se vazio ou < 2 caracteres -- `counts.total` — agendadas na semana **sem** search -- `counts.returned` — agendadas **com** search - -### `GET /api/workorders/board/search` - -Advanced search cross-week paginado. - -**Autenticação:** Bearer JWT - -**Query params:** - -| Param | Tipo | Descrição | -|-------|------|-----------| -| `search` | string | Texto livre (mesmos campos do contextual) | -| `datePreset` | enum | `thisWeek`, `lastWeek`, `thisMonth`, `last3Months`, `nextWeek`, `nextMonth`, `custom` | -| `dateFrom` / `dateTo` | date | Obrigatórios se `datePreset=custom` | -| `sites` | string[] | `SiteCode` | -| `types` | WorkOrderType[] | Tipo WO | -| `dispatchers` | string[] | GUIDs + `__unassigned__` | -| `statuses` | LifecycleStatus[] | Status operacional | -| `pmTypes` | string[] | Match em `Trade`/`Problem` (contains, case-insensitive) | -| `vendorIds` | int[] | Via `PrimaryDispatch.VendorId` | -| `docStatuses` | DocStatus[] | Enum `DocStatus` | -| `myWorkOrders` | bool | Filtro usuário logado | -| `page` | int | Default 1 | -| `pageSize` | int | Default 50; max 100 (Tier S/M) | -| `sortBy` | string | `scheduledDate` (default), `woNumber`, `dueDate` | -| `sortDir` | string | `asc` / `desc` | - -**Response:** `PagedResult` - -```json -{ - "items": [ /* WorkOrderBoardRowDto */ ], - "totalCount": 120, - "page": 1, - "pageSize": 50, - "totalPages": 3, - "hasNext": true, - "hasPrevious": false -} -``` - -**Exemplo:** - -```bash -curl -H "Authorization: Bearer " \ - "https://localhost:5001/api/workorders/board/search?datePreset=thisMonth&sites=BK5&search=HVAC&page=1" -``` - ---- - -## Matriz preset → intervalo de datas - -Base: **segunda-feira ISO** (`WorkOrderSearchDateRangeResolver`). - -| Preset | Intervalo | -|--------|-----------| -| `thisWeek` | Seg–Dom da semana ISO corrente | -| `lastWeek` | Seg–Dom da semana ISO anterior | -| `nextWeek` | Seg–Dom da próxima semana ISO | -| `thisMonth` | 1º–último dia do mês corrente | -| `nextMonth` | 1º–último dia do mês seguinte | -| `last3Months` | Hoje − 3 meses → hoje | -| `custom` | `dateFrom` / `dateTo` (400 se ausentes) | - -**Filtro de data:** `ScheduledDate` no intervalo **OU** `ScheduleWeekOnly && TargetWeek` intersectando o intervalo. Exclui templates e `IsDeleted`. - ---- - -## Código entregue - -| Camada | Arquivo | -|--------|---------| -| Search filter | `SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs` | -| Query filters | `SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs` | -| Projeção | `SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs` | -| Date presets | `SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs` | -| Advanced data | `SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs` | -| Advanced service | `SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs` | -| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | -| API | `WorkOrderController` — `GET board/search` | -| Migration | `20260624200000_Phase4_SearchIndexes.cs` | -| Testes | `SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs` | -| Load test | `scripts/load-test/work-order-search.k6.js` | -| Seed | `scripts/seed-work-orders-search.ps1` | - ---- - -## Gates de aceite - -### Dev (implementação) - -- [x] Helper de search compartilhado + testes por campo -- [x] `GET /board/search` paginado com filtros FE -- [x] Date presets alinhados ao SHOC (ISO Monday) -- [x] Migration índices Phase 4 -- [x] Harness k6 + seed sintético (Tier S local) -- [x] Endpoints legados inalterados -- [x] ~12 testes unitários novos (total ~80) - -### Staging/prod (GO — pendente) - -- [ ] Volume Discovery preenchido — [volume-discovery-report.md](../phase-0/volume-discovery-report.md) -- [ ] Tier assinado — [search-tier-decision.md](./search-tier-decision.md) -- [ ] Load test em staging com volume real -- [ ] Latência dentro do SLO do tier confirmado -- [ ] Smoke Portal/Blazor inalterados - ---- - -## SLOs Tier S (load test local) - -| Cenário | Endpoint | p95 | -|---------|----------|-----| -| Board sem search | `GET /board?weekStart=...` | < 800ms | -| Board com search | `GET /board?search=BK5` | < 1000ms | -| Advanced preset | `GET /board/search?datePreset=thisMonth` | < 1200ms | -| Advanced multi-filter | sites + types + search | < 1500ms | - -Ver [scripts/load-test/README.md](../../scripts/load-test/README.md). - ---- - -## Fora de escopo - -- Full-text search (Tier L) -- Search externo dedicado (Tier XL) -- Lookups PM catalog (`PM_TYPES`) — backlog #17 -- Alterações em endpoints legados diff --git a/docs/work-orders/phase-4/search-tier-decision.md b/docs/work-orders/phase-4/search-tier-decision.md deleted file mode 100644 index da7dcfe..0000000 --- a/docs/work-orders/phase-4/search-tier-decision.md +++ /dev/null @@ -1,51 +0,0 @@ -# Search Tier Decision — Fase 4 - -**Data:** 2026-06-24 -**Status:** Provisório (dev) — aguardando Volume Discovery staging/prod - ---- - -## Tier confirmado - -| Ambiente | Tier | Total WOs | Decisão | -|----------|------|-----------|---------| -| Dev local | **S (provisório)** | < 25k sintético | Implementar SQL filtros + paginação opcional | -| Staging | _pendente_ | _pendente acesso_ | Executar [volume-discovery-report.md](../phase-0/volume-discovery-report.md) | -| Produção | _pendente_ | _pendente acesso_ | Executar queries read-only | - ---- - -## Estratégia por tier - -| Tier | Volume | Estratégia advanced search | -|------|--------|----------------------------| -| **S** | < 25k | SQL filtros compostos + paginação default 50, max 100 | -| **M** | 25k–250k | Paginação **obrigatória** + índices covering | -| **L** | 250k–1M | Spike full-text SQL Server — **não implementar sem spike** | -| **XL** | > 1M | Documentar ARB; search externo — **não implementar** | - ---- - -## Implementação atual (Tier S) - -- `WorkOrderBoardSearchFilter` — filtro textual compartilhado (board + advanced) -- `WorkOrderAdvancedSearchDataService` — query composta com `CountAsync` + `Skip/Take` -- Índices Phase 4: `SiteCode`, `InternalWONumber`, `LifecycleStatus+ScheduledDate` -- Min 2 caracteres em `search` para evitar full-table scan - ---- - -## Próximos passos (gate staging) - -1. Executar `scripts/volume-discovery.ps1` contra staging read-only -2. Preencher tabela §3 do volume report -3. Atualizar este documento com tier real -4. Repetir load test k6 em staging; ajustar `pageSize` max se tier = M - ---- - -## Referências - -- [volume-discovery-report.md](../phase-0/volume-discovery-report.md) -- [phase-4/README.md](./README.md) -- Roadmap §10, §12 Fase 4 diff --git a/docs/work-orders/phase-5/README.md b/docs/work-orders/phase-5/README.md deleted file mode 100644 index a1a3bfb..0000000 --- a/docs/work-orders/phase-5/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# Fase 5 — Scheduled Domain Events - -**Programa:** Work Orders Board -**Objetivo:** Jobs agendados in-process (`IHostedService`) para `WeekRolled` → `CarriedOver++` com idempotência WO+semana, audit System, cache opcional de `PastDue`, e garantia de que leituras do board continuam on-read como fonte da verdade. - -**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md), [Fase 3](../phase-3/README.md), [Fase 4](../phase-4/README.md) - ---- - -## Regra de elegibilidade WeekRolled - -```text -sourceWeek = semana operacional encerrada (segunda a sexta UTC) -Elegível se: - - ScheduledDate.Date ∈ [sourceWeekStart, sourceWeekEnd] - - LifecycleStatus ∉ {Complete, Canceled, Closed} - - istemplate != true (ou null) - - Ainda não processado em WorkOrderWeekRolledLedger (WorkOrderId + SourceWeekStart) -``` - -**Gate PO (pendente):** confirmar se WOs só com `TargetWeek` (sem `ScheduledDate`) entram no carry-over. A implementação atual considera **apenas** WOs com `ScheduledDate` na janela. - ---- - -## Idempotência - -Chave composta `(WorkOrderId, SourceWeekStart)` na tabela `WorkOrderWeekRolledLedger`. Re-execução do job ou endpoint admin na mesma semana não duplica `CarriedOver`. - ---- - -## Contrato de audit - -Um evento `WeekRolled` por WO processado: - -| Campo | Valor | -|-------|-------| -| `EventType` | `System` | -| `ActorType` | `System` | -| `Action` | `WeekRolled` | -| `FieldName` | `CarriedOver` | -| `OldValue` / `NewValue` | numéricos (string) | -| `CorrelationId` | `week:{yyyy-MM-dd}` (segunda da semana fonte) | - -Não aciona `ManualEditFlag` nem field locks. - ---- - -## Regra de ouro — isPastDue - -Jobs **nunca** são fonte da verdade para `isPastDue`. O board continua calculando via `WorkOrderDerivedFields.IsPastDue` on-read. O cache `OperationalFlags.PastDue` é otimização opcional (`WorkOrderJobs:PastDueCache:Enabled`, default **false**). - ---- - -## Configuração - -```json -"WorkOrderJobs": { - "WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" }, - "PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" } -} -``` - -Variáveis de ambiente (`.env.example`): - -```text -WorkOrderJobs__WeekRolled__Enabled=true -WorkOrderJobs__PastDueCache__Enabled=false -``` - ---- - -## Endpoints admin (ops-only) - -| Endpoint | Auth | Descrição | -|----------|------|-----------| -| `POST /api/workorders/jobs/week-rolled?sourceWeekStart=2026-06-16` | Admin | Reprocessa semana (segunda-feira). Idempotente. | -| `POST /api/workorders/jobs/past-due-cache` | Admin | Atualiza cache `OperationalFlags.PastDue` | - ---- - -## Código entregue - -| Camada | Arquivo | -|--------|---------| -| Ledger | `Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs` | -| Migration | `20260624210000_Phase5_DomainEvents.cs` | -| Data | `SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs` | -| WeekRolled | `SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs` | -| PastDue cache | `SeaHaven.Services/Implementation/PastDueCacheService.cs` | -| Hosted | `Api.SeaHavenIndustries/HostedServices/*.cs` | -| API | `WorkOrderJobsController` | -| Testes | `SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs` | - ---- - -## Monitoramento - -Logs estruturados com `CorrelationId` da semana. Ao concluir: `processed`, `skipped`, `failed`, `durationMs`. - -**Alerta ops:** se `failed > 0` ou job não executou em 8 dias → investigar + `POST /jobs/week-rolled` manual. - ---- - -## Critérios de aceite - -- [x] Job semanal incrementa `carriedOver` para WOs elegíveis da semana anterior -- [x] Re-execução na mesma semana não duplica (ledger WO+semana) -- [x] Audit `WeekRolled` System com old/new `CarriedOver` e `CorrelationId` -- [x] `GET /api/workorders/board` continua com `isPastDue` derivado on-read -- [x] Endpoint admin permite reprocessar semana específica -- [x] Testes cobrem idempotência, terminal skip, audit e on-read correctness - ---- - -## Fora de escopo - -- Message queue / worker externo -- Alteração do contrato REST do board para usar `OperationalFlags` -- WOs week-only (`TargetWeek` sem `ScheduledDate`) — gate PO diff --git a/docs/work-orders/phase-6/README.md b/docs/work-orders/phase-6/README.md deleted file mode 100644 index 7ec8117..0000000 --- a/docs/work-orders/phase-6/README.md +++ /dev/null @@ -1,159 +0,0 @@ -# Fase 6 — Completion Doc e Slide-over - -**Programa:** Work Orders Board -**Objetivo:** Contrato SHOC para slide-over (`WOSlideOver`): detalhe unificado, `docStatus` WO-level com templates por serviço, e tabs Comments/Audit/Media alinhadas ao frontend. - -**Depende de:** [Fase 0](../phase-0/README.md) … [Fase 5](../phase-5/README.md) - ---- - -## Endpoints - -### `GET /api/workorders/{id}/detail` - -Payload único para abrir o slide-over. - -**Autenticação:** Bearer JWT (`[Authorize]`) - -**Response:** - -```json -{ - "info": { /* WorkOrderDetailInfoDto — board row + description/trade/original* */ }, - "completion": { - "docStatus": "No", - "template": { "id": 1, "name": "HVAC PM Completion", "serviceKey": "HVAC PM", "templateUrl": "..." }, - "signOffName": null, - "signOffAttachment": null, - "signOffSignature": null, - "dispatchSignoffs": [] - }, - "comments": [{ "id": 1, "authorId": "guid", "text": "...", "time": "2026-06-01T12:00:00.0000000Z" }], - "audit": [{ "type": "manual", "dispatcherId": "guid", "action": "FieldChanged", "fieldName": "DocStatus", "oldValue": "No", "newValue": "Yes", "time": "..." }], - "media": [{ "id": 5, "category": "Extra", "url": "...", "isLegacy": false }] -} -``` - -```bash -curl -H "Authorization: Bearer " \ - "https://localhost:5001/api/workorders/42/detail" -``` - ---- - -### `GET /api/workorders/{id}/audit?limit=50` - -Audit tab lazy refresh. Schema FE: `{ type, dispatcherId?, action, fieldName?, oldValue, newValue, time, dispatchId? }`. - ---- - -### `GET /api/workorders/{id}/comments` - -### `POST /api/workorders/{id}/comments` - -```json -{ "text": "Called vendor" } -``` - -Response: `{ id, authorId, text, time, documents? }`. - ---- - -### Completion templates - -| Método | Rota | Auth | Descrição | -|--------|------|------|-----------| -| GET | `/api/workorders/completion-templates` | JWT | Query `serviceKey`, `workOrderType` | -| GET | `/api/workorders/completion-templates/{id}` | JWT | Detalhe | -| POST | `/api/workorders/completion-templates` | Admin | Criar template | -| PUT | `/api/workorders/completion-templates/{id}` | Admin | Atualizar | -| DELETE | `/api/workorders/completion-templates/{id}` | Admin | Soft delete | - -Lookup na WO: `Trade` → fallback `WorkOrderType`. - ---- - -### `POST /api/workorders/{id}/completion-doc` - -Upload PDF preenchido (multipart). - -| Campo form | Obrigatório | -|------------|-------------| -| `file` | Sim | -| `signOffName` | Não | -| `signOffSignature` | Não | -| `workOrderVersion` | Recomendado (Base64 RowVersion) | - -**Regras:** -- WO read-only (`Canceled`/`Complete`/`Closed`) → 422 -- Sucesso → `SignOffAttachment` + `DocStatus=Yes` + audit `FieldChanged` -- Dispatch signoffs → somente leitura no slide-over - -```bash -curl -X POST -H "Authorization: Bearer " \ - -F "file=@completion.pdf" \ - -F "signOffName=Jane Doe" \ - "https://localhost:5001/api/workorders/42/completion-doc" -``` - -**Inline toggle:** `PATCH /api/workorders/{id}/board` com `field=docStatus` (Fase 2). - ---- - -### Media - -| Método | Rota | Descrição | -|--------|------|-----------| -| GET | `/api/workorders/{id}/media` | Lista unificada com `category` | -| POST | `/api/workorders/{id}/media` | multipart: `category` (Before/After/Extra/Completion) + `file` | -| DELETE | `/api/workorders/{id}/media/{mediaId}` | Soft delete (somente attachments com `id > 0`) | - -Legacy columns (`BeforPhotoAttachment`, `AfterPhotoAttachment`, `SignOffAttachment`) aparecem na projeção com `isLegacy: true`. - ---- - -## Schema - -Migration `Phase6_CompletionSlideOver`: - -- Tabela `CompletionDocTemplates` -- Coluna `Category` em `workOrderAttachments` -- Backfill SQL: `SignOffAttachment` preenchido → `DocStatus=Yes` - -Script ops: [`scripts/backfill-docstatus.ps1`](../../scripts/backfill-docstatus.ps1) - ---- - -## Código entregue - -| Camada | Arquivo | -|--------|---------| -| Model | `CompletionDocTemplate.cs`, `WorkOrderMediaCategory` enum | -| Migration | `20260625120000_Phase6_CompletionSlideOver.cs` | -| Data | `WorkOrderDetailDataService`, `CompletionDocTemplateDataService` | -| Services | `WorkOrderDetailService`, `WorkOrderCommentService`, `WorkOrderCompletionService`, `WorkOrderMediaService` | -| DTOs | `WorkOrderDetailDTOs.cs` | -| Helpers | `WorkOrderAuditProjection`, `WorkOrderCommentProjection`, `WorkOrderMediaProjection` | -| API | `WorkOrderController` — rotas `/detail`, `/comments`, `/audit`, `/completion-doc`, `/media`, `/completion-templates` | -| Testes | `WorkOrderPhase6Tests.cs` | - ---- - -## Critérios de aceite - -- [x] `GET /detail` retorna Info + Completion + Comments + Audit + Media no contrato FE -- [x] Coluna COMP DOC do board via `docStatus` no `WorkOrderBoardRowDto` (Fase 1) -- [x] `CompletionDocTemplate` CRUD admin + lookup por `serviceKey`/`workOrderType` -- [x] `POST completion-doc` persiste attachment, define `DocStatus=Yes`, audit -- [x] Comments/Audit tabs sem adapter no FE -- [x] Media com `category`; legado mapeado -- [x] Endpoints legados (`GetWorkorderById`, `GetCommentsByWorkorderId`) inalterados -- [x] Testes: DocStatus PATCH, detail, completion upload, comments, media - ---- - -## Fora de escopo - -- Geração de PDF server-side -- Alteração Vendor Portal checklist/signoff -- Rollout / sunset Blazor (Fase 7) diff --git a/docs/work-orders/phase-7/README.md b/docs/work-orders/phase-7/README.md deleted file mode 100644 index 61cfe2b..0000000 --- a/docs/work-orders/phase-7/README.md +++ /dev/null @@ -1,245 +0,0 @@ -# Fase 7 — Rollout e Produção - -**Programa:** Work Orders Board -**Objetivo:** Piloto SHOC, rollout gradual, monitoramento de coexistência, sunset Blazor WO, cutover Lambda→API e deprecação legado. - -**Depende de:** [Fase 0](../phase-0/README.md) … [Fase 6](../phase-6/README.md) concluídas e validadas em staging. - ---- - -## Gates - -- **Entrada:** [phase-7-gates-signoff.md](phase-7-gates-signoff.md) -- **Saída:** piloto UAT, 100% SHOC, zero P1 × 2 semanas, Blazor sunset, Lambda cutover, ops health ativo - ---- - -## Runbook de rollout (ordem obrigatória) - -1. Fechar gates de entrada Fase 7 -2. Deploy API com ops health + ingest (ingest desabilitado em prod inicialmente) -3. SHOC: feature flag board em staging → piloto 1–2 dispatchers em prod -4. UAT dispatchers ([roteiros abaixo](#uat-dispatchers)) -5. Rollout gradual SHOC até 100% -6. Habilitar dual-run Lambda (`ingest` + Dynamo) -7. Validar drift → cutover Lambda só API -8. `Sync:Enabled=false` -9. `BlazorWorkOrderSunset:Enabled=true` -10. `LegacyEndpoints:DeprecationEnabled=true` + data Sunset -11. Remover Sync/legado após período de aviso (30 dias) - ---- - -## Endpoints Fase 7 - -### `GET /api/workorders/ops/health` - -Saúde operacional para coexistência multi-consumidor. - -**Auth:** Admin JWT - -```json -{ - "lastWeekRolledRunUtc": "2026-06-23T00:05:12Z", - "lastPastDueCacheRunUtc": null, - "lastWeekRolledError": null, - "lastPastDueCacheError": null, - "syncRejectedLast24h": 3, - "fieldLockCount": 142, - "syncEnabled": true, - "ingestEnabled": true, - "legacyDeprecationEnabled": false, - "legacySunsetDate": null, - "checkedAtUtc": "2026-06-25T12:00:00Z" -} -``` - -Ver [cloudwatch-alerts.md](cloudwatch-alerts.md). - ---- - -### `POST /api/workorders/ingest` - -Ingest direto idempotente (substitui ponte DynamoDB para WOs). - -**Auth:** header `X-Ingest-Key` (config `WorkOrderIngest:ApiKey`) - -**Body (single ou batch):** - -```json -{ - "externalWorkOrderId": "ext-wo-12345", - "description": "HVAC unit not cooling", - "woStatus": "new", - "severity": "2", - "customer": "Acme Corp", - "siteCode": "BK5", - "building": "Building A", - "address": "123 Main St", - "dueDate": "2026-07-01T00:00:00Z", - "dateReported": "2026-06-20T10:00:00Z", - "scheduledStart": null, - "sourceEmailS3Key": null, - "createdAt": "2026-06-20T10:00:00Z" -} -``` - -**Batch:** - -```json -{ - "items": [ { "externalWorkOrderId": "...", "description": "..." } ] -} -``` - -**Response:** - -```json -{ - "created": 1, - "updated": 0, - "results": [{ "externalWorkOrderId": "ext-wo-12345", "workOrderId": 42, "created": true }] -} -``` - -Mapeamento completo: [lambda-ingest-discovery.md](lambda-ingest-discovery.md) - ---- - -## Matriz de endpoints - -| Tipo | Exemplos | Política Fase 7 | -|------|----------|-----------------| -| Board (SHOC) | `GET board`, `PATCH {id}/board`, `POST board` | **Produção** | -| Slide-over | `GET {id}/detail`, completion-doc, media | **Produção** | -| Ingest | `POST ingest` | Dual-run → produção | -| Sync | `POST api/Sync/WorkOrders` | Desligar após cutover (`Sync:Enabled`) | -| Legado | `GetWorkOrderList`, `AddWorkorder`, `ChangeStatus` | Deprecation headers | -| Jobs | `POST jobs/week-rolled` | Ops Admin | - ---- - -## Configuração - -### API (`Api.SeaHavenIndustries`) - -```json -{ - "FrontendBaseUrl": "https://shoc.seahaven.com", - "WorkOrderIngest": { - "Enabled": true, - "ApiKey": "${WORKORDER_INGEST_API_KEY}" - }, - "Sync": { - "Enabled": true - }, - "LegacyEndpoints": { - "DeprecationEnabled": false, - "SunsetDate": "2026-12-31" - } -} -``` - -Env vars: ver [.env.example](../../../.env.example) - -### Blazor (`SeaHavenIndustries`) - -```json -{ - "BlazorWorkOrderSunset": { - "Enabled": false, - "ShocBaseUrl": "https://shoc.seahaven.com" - } -} -``` - -Quando `Enabled=true`: nav Work Orders aponta para SHOC; mutações EF bloqueadas; banner nas páginas WO. - ---- - -## UAT dispatchers - -Roteiro manual — marcar cada item antes de expandir rollout. - -| # | Cenário | Pass | -|---|---------|------| -| 1 | Login SHOC; carregar semana (Mon–Fri + Unscheduled) | [ ] | -| 2 | Filtros: dispatcher, My WOs, tipo, busca contextual | [ ] | -| 3 | Inline edit célula (status, assignee, due) | [ ] | -| 4 | Conflito 409 — outro usuário editou; mensagem clara | [ ] | -| 5 | Criar WO wizard (Incomplete → Scheduled auto) | [ ] | -| 6 | Cancel soft; campos read-only pós-cancel | [ ] | -| 7 | Advanced search cross-week | [ ] | -| 8 | Slide-over: detail, comments, audit, media | [ ] | -| 9 | Upload completion doc; docStatus no board | [ ] | -| 10 | Badge PastDue / CarriedOver após segunda-feira | [ ] | - -**Sign-off UAT** - -| Dispatcher | Data | PO | -|------------|------|-----| -| | | | - ---- - -## Piloto e rollout SHOC - -Controle **somente no frontend** (feature flags). Backend não filtra dispatchers. - -Critérios para avançar: - -- Zero P1 na semana do piloto -- `GET board` latência aceitável (Tier S) -- Vendor Portal regression verde - ---- - -## Sunset Blazor - -Ver [data-ownership-model.md](../phase-0/data-ownership-model.md) §3. - -1. Congelar `WorkorderService` (bugfix only) até data PO -2. Habilitar `BlazorWorkOrderSunset:Enabled` -3. Smoke final [smoke-checklist.md](../phase-0/smoke-checklist.md) Blazor EF - ---- - -## Cutover Lambda - -Ver [lambda-ingest-discovery.md](lambda-ingest-discovery.md). - ---- - -## Testes automatizados - -```powershell -dotnet test SeaHavenIndustries.Tests --filter "FullyQualifiedName~WorkOrderPhase7" -``` - -Suite: `WorkOrderPhase7CoexistenceTests.cs` - ---- - -## Critérios de aceite Fase 7 - -- [x] `GET /api/workorders/ops/health` -- [x] `POST /api/workorders/ingest` com API key -- [x] `Sync:Enabled` feature flag -- [x] Legacy deprecation middleware -- [x] Blazor sunset config + guard mutações -- [x] Documentação gates, UAT, Lambda mapping -- [ ] Piloto prod (ops/PO) -- [ ] 100% dispatchers SHOC (ops/PO) -- [ ] Zero P1 × 2 semanas (ops/PO) -- [ ] Lambda cutover executado (ops) - ---- - -## Comunicação - -| Audiência | Mensagem | Quando | -|-----------|----------|--------| -| Dispatchers piloto | Novo board SHOC; suporte dedicado | Início piloto | -| Todos dispatchers | Rollout gradual; treinamento | Durante rollout | -| Usuários Blazor | WO migrou para SHOC; link direto | Sunset Blazor | -| Engenharia | Sync desligado; usar ingest API | Pós-cutover | diff --git a/docs/work-orders/phase-7/cloudwatch-alerts.md b/docs/work-orders/phase-7/cloudwatch-alerts.md deleted file mode 100644 index d83e929..0000000 --- a/docs/work-orders/phase-7/cloudwatch-alerts.md +++ /dev/null @@ -1,40 +0,0 @@ -# Alertas Operacionais — Fase 7 - -**Endpoint de saúde:** `GET /api/workorders/ops/health` (Admin JWT) - ---- - -## Métricas expostas - -| Campo | Fonte | Uso | -|-------|-------|-----| -| `lastWeekRolledRunUtc` | `WorkOrderJobRunState` + ledger | Job segunda-feira | -| `lastPastDueCacheRunUtc` | `WorkOrderJobRunState` | Cache opcional PastDue | -| `syncRejectedLast24h` | `WorkOrderAuditLogs` Action=SyncRejected | Conflito SHOC vs ingest | -| `fieldLockCount` | `WorkOrderFieldLocks` | Adoção edição manual | -| `syncEnabled` | `Sync:Enabled` | Estado ponte Dynamo | -| `ingestEnabled` | `WorkOrderIngest:Enabled` | Ingest direto ativo | - ---- - -## Alertas recomendados (Elastic Beanstalk / CloudWatch) - -| Alerta | Condição | Severidade | -|--------|----------|------------| -| WeekRolled stale | `lastWeekRolledRunUtc` > 8 dias | P1 | -| WeekRolled job error | `lastWeekRolledError` não nulo | P1 | -| SyncRejected spike | `syncRejectedLast24h` > 50 | P2 | -| API 5xx board | ALB/Beanstalk 5xx rate > 1% em `/api/workorders/board` | P1 | -| Ingest auth failures | 401 em `/api/workorders/ingest` > 10/h | P2 | -| Sync disabled em prod sem cutover | `syncEnabled=false` e ingest não validado | P2 | - ---- - -## Verificação manual (ops) - -```powershell -# Com token Admin -curl -H "Authorization: Bearer " https:///api/workorders/ops/health -``` - -Agendar checagem diária durante piloto e rollout. diff --git a/docs/work-orders/phase-7/lambda-ingest-discovery.md b/docs/work-orders/phase-7/lambda-ingest-discovery.md deleted file mode 100644 index b07d789..0000000 --- a/docs/work-orders/phase-7/lambda-ingest-discovery.md +++ /dev/null @@ -1,93 +0,0 @@ -# Lambda Ingest — Descoberta e Mapeamento de Campos - -**Fase:** 7 -**Status:** Documentação de cutover (código Lambda fora deste repositório) - ---- - -## 1. Localização da Lambda - -| Item | Valor | -|------|-------| -| Nome referenciado | `workorder-ingest` | -| Referência código | [TODO.md](../../../TODO.md) L50–51 | -| Fluxo atual | Lambda → DynamoDB (`WorkOrders`) → `POST api/Sync/WorkOrders` | -| Fluxo alvo | Lambda → `POST api/workorders/ingest` (API key) | - -**Ação pendente ops:** localizar repositório/infra AWS (SAM, Terraform, console Lambda) e preencher owner na [phase-7-gates-signoff.md](phase-7-gates-signoff.md). - ---- - -## 2. Tabelas DynamoDB (SyncController) - -| Tabela | Endpoint sync | Uso | -|--------|---------------|-----| -| `WorkOrders` | `POST api/Sync/WorkOrders` | Upsert WO por `work_order_id` | -| `WorkOrderComments` | `POST api/Sync/Comments` | Comentários cliente | -| `VendorReplies` | `POST api/Sync/VendorReplies` | Respostas vendor | - -Cutover Fase 7 foca em **WorkOrders**; comments/replies permanecem no Sync até migração separada. - ---- - -## 3. Mapeamento DynamoDB → API ingest - -Campos lidos em [SyncController.cs](../../../Api.SeaHavenIndustries/Controllers/SyncController.cs) e espelhados em `WorkOrderIngestPayload`: - -| Campo Dynamo | Campo API ingest | Campo SQL | Merge policy (update) | -|--------------|------------------|-----------|------------------------| -| `work_order_id` | `externalWorkOrderId` | `ExternalWorkOrderId` | Chave idempotente | -| `description` | `description` | `Description`, `WorkerOrderTitle` | Sim se não locked | -| `wo_status` | `woStatus` | `Status` (mapeado) | Sim se não locked | -| `severity` | `severity` | `Priority`, `Severity` | Sim se não locked | -| `customer` | `customer` | `Customer` | Direto na criação | -| `site_code` | `siteCode` | `SiteCode` | Sim se não locked | -| `building` | `building` | `Building` | Sim se não locked | -| `address` | `address` | `Locations` (resolve/create) | LocationId | -| `due_date` | `dueDate` | `DueDate` | Sim se não locked | -| `date_reported` | `dateReported` | `DateReported` | Direto | -| `scheduled_start` | `scheduledStart` | `ScheduledStart` | Direto | -| `source_email_s3_key` | `sourceEmailS3Key` | `SourceEmailS3Key` | Direto | -| `created_at` | `createdAt` | `CreatedDate` | Criação only | - -### Mapeamento `wo_status` → SQL `Status` - -| Dynamo | SQL | -|--------|-----| -| `new`, `assigned`, `unknown` | `Open` | -| `in_progress` | `In Progress` | -| `on_hold` | `On Hold` | -| `completed` | `Done` | -| `cancelled` | `Cancelled` | - -### Mapeamento `severity` → `Priority` - -`Sev {severity}` (ex.: `3` → `Sev 3`) - ---- - -## 4. Auth serviço-a-serviço - -| Header | Config | -|--------|--------| -| `X-Ingest-Key` | `WorkOrderIngest:ApiKey` (env `WorkOrderIngest__ApiKey`) | - -Lambda deve enviar o header em cada `POST /api/workorders/ingest`. Não usar JWT de usuário dispatcher. - ---- - -## 5. Estratégia dual-run - -1. **Semana 1–2:** Lambda grava DynamoDB **e** chama API ingest -2. **Validação:** `GET /api/workorders/ops/health` + script amostra `ExternalWorkOrderId` -3. **Cutover:** Lambda só API; `Sync:Enabled=false` -4. **Retire:** backup DynamoDB → desativar tabelas - ---- - -## 6. Critérios de cutover - -- [ ] `POST /api/workorders/board` estável (criação SHOC) -- [ ] Auth ingest testada em staging -- [ ] Zero drift em amostra de 100 WOs -- [ ] Owner Lambda assinou runbook diff --git a/docs/work-orders/phase-7/phase-7-gates-signoff.md b/docs/work-orders/phase-7/phase-7-gates-signoff.md deleted file mode 100644 index 16021e0..0000000 --- a/docs/work-orders/phase-7/phase-7-gates-signoff.md +++ /dev/null @@ -1,55 +0,0 @@ -# Phase 7 — Gates Sign-Off Checklist - -**Programa:** Work Orders Board -**Fase:** 7 — Rollout e Produção - ---- - -## Gate de entrada (GO Fase 7) - -Bloqueia piloto em produção até todos os itens estarem verdes. - -| # | Gate | Artefato / evidência | Status | -|---|------|----------------------|--------| -| 1 | Fases 0–6 validadas em staging | `docs/work-orders/phase-0` … `phase-6` READMEs | [ ] | -| 2 | Sign-off CTO/PO Fase 0 | [phase-0-gates-signoff.md](../phase-0/phase-0-gates-signoff.md) | [ ] | -| 3 | Dry-run migrations Phase0–Phase6 | [migration-dry-run-report.md](../phase-0/migration-dry-run-report.md) | [ ] | -| 4 | Smoke multi-consumidor | [smoke-checklist.md](../phase-0/smoke-checklist.md) | [ ] | -| 5 | Vendor Portal regression | Checklist dispatch checklist/signoff | [ ] | -| 6 | SHOC E2E staging | Contratos phase-1 … phase-6 integrados | [ ] | -| 7 | Tier volume confirmado | [volume-discovery-report.md](../phase-0/volume-discovery-report.md) | [ ] | - -### Checklist PO ([consumer-inventory.md](../phase-0/consumer-inventory.md)) - -- [ ] App mobile externo confirmado (sim/não/N/A) -- [ ] Data sunset Blazor WO definida: _______________ -- [ ] `FrontendBaseUrl` produção → SHOC configurado -- [ ] Owner Lambda cutover nomeado: _______________ - -**Assinatura GO Fase 7** - -| Papel | Nome | Data | -|-------|------|------| -| CTO | | | -| PO | | | - ---- - -## Gates de saída (DONE Fase 7) - -| Gate | Critério | -|------|----------| -| Piloto | 1–2 dispatchers SHOC em prod; UAT assinado | -| Rollout | 100% dispatchers no board SHOC | -| Estabilidade | Zero P1 por 2 semanas consecutivas | -| Blazor | Módulo WO sunset (`BlazorWorkOrderSunset:Enabled`) | -| Lambda | Ingest direto `POST /api/workorders/ingest`; Dynamo/Sync desligados | -| Monitoramento | `GET /api/workorders/ops/health` + alertas documentados | -| Legado | Headers `Sunset`/`Deprecation` nos endpoints legado | - -**Assinatura DONE Fase 7** - -| Papel | Nome | Data | -|-------|------|------| -| CTO | | | -| PO | | | diff --git a/scripts/backfill-docstatus.ps1 b/scripts/backfill-docstatus.ps1 deleted file mode 100644 index 4bcc0e0..0000000 --- a/scripts/backfill-docstatus.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# Backfill DocStatus from legacy SignOffAttachment (idempotent) -# Run against target SQL Server after Phase6_CompletionSlideOver migration. - -param( - [string]$ConnectionString = $env:ConnectionStrings__DefaultConnection -) - -if ([string]::IsNullOrWhiteSpace($ConnectionString)) { - Write-Error "Set ConnectionStrings__DefaultConnection or pass -ConnectionString" - exit 1 -} - -$sql = @" -UPDATE workOrders -SET DocStatus = 1 -WHERE DocStatus IS NULL - AND SignOffAttachment IS NOT NULL - AND LTRIM(RTRIM(SignOffAttachment)) <> ''; -"@ - -Write-Host "Backfilling DocStatus=Yes where SignOffAttachment exists..." -Invoke-Sqlcmd -ConnectionString $ConnectionString -Query $sql -Write-Host "Done." diff --git a/scripts/seed-work-orders-search.ps1 b/scripts/seed-work-orders-search.ps1 deleted file mode 100644 index a08b18a..0000000 --- a/scripts/seed-work-orders-search.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -# Seed sintético de Work Orders para load test Tier S -# -# Uso: -# $env:SQL_CONNECTION_STRING = "Server=localhost;Database=SeaHaven;..." -# .\scripts\seed-work-orders-search.ps1 -Count 15000 - -param( - [int]$Count = 5000, - [string]$ConnectionString = $env:SQL_CONNECTION_STRING -) - -if ([string]::IsNullOrWhiteSpace($ConnectionString)) { - Write-Error "Defina SQL_CONNECTION_STRING ou passe -ConnectionString." - exit 1 -} - -$sites = @("BK5", "BK6", "BK7", "BK8", "BK9", "LA1", "LA2", "NY1") -$trades = @("HVAC PM", "Plumbing PM", "Electrical PM", "Roof Inspection", "Fire Safety") -$baseDate = Get-Date "2026-01-06" - -Write-Host "Inserindo $Count WOs sintéticas..." -ForegroundColor Cyan - -$batchSize = 500 -$inserted = 0 - -while ($inserted -lt $Count) { - $batch = [Math]::Min($batchSize, $Count - $inserted) - $values = @() - - for ($i = 0; $i -lt $batch; $i++) { - $id = $inserted + $i + 1 - $site = $sites[$id % $sites.Length] - $trade = $trades[$id % $trades.Length] - $weekOffset = $id % 52 - $scheduled = $baseDate.AddDays($weekOffset * 7 + ($id % 5)) - $woNumber = ("1{0:D10}" -f $id) - - $values += "('$woNumber', '$site', '$trade', '$($scheduled.ToString('yyyy-MM-dd'))', 2, 0)" - } - - $sql = @" -INSERT INTO workOrders (InternalWONumber, SiteCode, Trade, ScheduledDate, LifecycleStatus, istemplate, RescheduleCount, CarriedOver, CreatedDate) -VALUES $($values -join ','); -"@ - - sqlcmd -Q $sql -b - if ($LASTEXITCODE -ne 0) { - Write-Error "Falha no batch $inserted" - exit 1 - } - - $inserted += $batch - Write-Host " $inserted / $Count" -} - -Write-Host "Seed concluído." -ForegroundColor Green diff --git a/scripts/spike-status-mapping.sql b/scripts/spike-status-mapping.sql deleted file mode 100644 index 14e371b..0000000 --- a/scripts/spike-status-mapping.sql +++ /dev/null @@ -1,90 +0,0 @@ --- Spike Status Mapping (G2): seed legado + enum compacto + queries de análise --- Executar APÓS dotnet ef database update --- Uso: sqlcmd -S localhost,1433 -U sa -P "SeaHaven_Dev_2026!" -d SeahavenIndustries -C -i scripts/spike-status-mapping.sql - -SET NOCOUNT ON; - --- --------------------------------------------------------------------------- --- 1. Seed status spike (prefixo SPIKE-STATUS-) --- Cobre formatos legado (com espaço) e canônico API (enum ToString) --- --------------------------------------------------------------------------- -IF NOT EXISTS (SELECT 1 FROM workOrders WHERE InternalWONumber LIKE 'SPIKE-STATUS-%') -BEGIN - DECLARE @weekStart date = CAST(GETDATE() AS date); - - INSERT INTO workOrders ( - InternalWONumber, WorkerOrderTitle, SiteCode, Status, Priority, - ScheduledDate, DueDate, Trade, Problem, AssignTo, istemplate, CreatedDate - ) - VALUES - -- Legado Blazor / Sync (com espaço) - ('SPIKE-STATUS-001', 'Status spike - Open legado', 'SS1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()), - ('SPIKE-STATUS-002', 'Status spike - In Progress legado','SS2', 'In Progress', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', NULL, 0, GETDATE()), - ('SPIKE-STATUS-003', 'Status spike - On Hold legado', 'SS3', 'On Hold', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 10, @weekStart), 'Electrical', 'Hold', NULL, 0, GETDATE()), - ('SPIKE-STATUS-004', 'Status spike - Done legado', 'SS4', 'Done', 'Low', DATEADD(day, 3, @weekStart), DATEADD(day, 4, @weekStart), 'General', 'Complete', NULL, 0, GETDATE()), - ('SPIKE-STATUS-005', 'Status spike - Cancelled legado', 'SS5', 'Cancelled', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'HVAC', 'Cancelled', NULL, 0, GETDATE()), - ('SPIKE-STATUS-006', 'Status spike - UnAssigned legado','SS6', 'UnAssigned', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 7, @weekStart), 'Plumbing', 'Unassigned', NULL, 0, GETDATE()), - -- Canônico API (enum ToString) - ('SPIKE-STATUS-007', 'Status spike - Open canônico', 'SS7', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()), - ('SPIKE-STATUS-008', 'Status spike - InProgress canônico','SS8', 'InProgress', 'High', DATEADD(day, 1, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', NULL, 0, GETDATE()), - ('SPIKE-STATUS-009', 'Status spike - OnHold canônico', 'SS9', 'OnHold', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 10, @weekStart), 'Electrical', 'Hold', NULL, 0, GETDATE()), - ('SPIKE-STATUS-010', 'Status spike - Completed canônico','SS10','Completed', 'Low', DATEADD(day, 3, @weekStart), DATEADD(day, 4, @weekStart), 'General', 'Complete', NULL, 0, GETDATE()), - ('SPIKE-STATUS-011', 'Status spike - Cancelled canônico','SS11','Cancelled', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'HVAC', 'Cancelled', NULL, 0, GETDATE()), - -- Open sem assignee (derivado Unassigned no board) - ('SPIKE-STATUS-012', 'Status spike - Open unassigned', 'SS12','Open', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 3, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()), - -- Past due candidato (scheduled no passado, status não terminal) - ('SPIKE-STATUS-013', 'Status spike - past due candidate','SS13','In Progress', 'High', DATEADD(day, -3, @weekStart), DATEADD(day, -1, @weekStart), 'HVAC', 'Overdue test', NULL, 0, GETDATE()); - - PRINT 'Seed status spike: 13 work orders inseridos (prefixo SPIKE-STATUS-).'; -END -ELSE - PRINT 'Seed status spike: já existente — pulando INSERT.'; -GO - --- --------------------------------------------------------------------------- --- 2. Distribuição Status (todos os WOs não-template) --- --------------------------------------------------------------------------- -PRINT '--- Distribuição Status ---'; -SELECT Status, COUNT(*) AS Cnt -FROM workOrders -WHERE istemplate IS NULL OR istemplate = 0 -GROUP BY Status -ORDER BY Cnt DESC; - --- --------------------------------------------------------------------------- --- 3. Status x assignee (detectar Unassigned implícito) --- --------------------------------------------------------------------------- -PRINT '--- Status x AssignTo (Unassigned implícito) ---'; -SELECT - Status, - CASE WHEN AssignTo IS NULL OR LTRIM(RTRIM(AssignTo)) = '' THEN 1 ELSE 0 END AS IsUnassigned, - COUNT(*) AS Cnt -FROM workOrders -WHERE istemplate IS NULL OR istemplate = 0 -GROUP BY Status, - CASE WHEN AssignTo IS NULL OR LTRIM(RTRIM(AssignTo)) = '' THEN 1 ELSE 0 END -ORDER BY Status, IsUnassigned; - --- --------------------------------------------------------------------------- --- 4. Órfãos — valores não mapeáveis pelo mapper G2 (ajustar lista conforme spike) --- --------------------------------------------------------------------------- -PRINT '--- Status órfãos (fora do vocabulário conhecido) ---'; -SELECT Status, COUNT(*) AS Cnt -FROM workOrders -WHERE (istemplate IS NULL OR istemplate = 0) - AND Status IS NOT NULL - AND LTRIM(RTRIM(Status)) NOT IN ( - 'Open', 'In Progress', 'On Hold', 'Done', 'Cancelled', 'UnAssigned', 'Unassigned', - 'InProgress', 'OnHold', 'Completed', - 'new', 'assigned', 'in_progress', 'on_hold', 'completed', 'cancelled', 'unknown' - ) -GROUP BY Status -ORDER BY Cnt DESC; - --- --------------------------------------------------------------------------- --- 5. Template query PRODUÇÃO (rodar manualmente em prod/staging) --- --------------------------------------------------------------------------- -PRINT '--- Template query PRODUÇÃO ---'; --- SELECT Status, COUNT(*) AS Cnt FROM workOrders WHERE istemplate IS NULL OR istemplate = 0 GROUP BY Status ORDER BY Cnt DESC; - -GO diff --git a/scripts/spike-work-order-type.sql b/scripts/spike-work-order-type.sql deleted file mode 100644 index 0c18465..0000000 --- a/scripts/spike-work-order-type.sql +++ /dev/null @@ -1,96 +0,0 @@ --- Spike WorkOrderType: coluna legada + seed local + queries de análise --- Executar APÓS dotnet ef database update --- Uso: sqlcmd -S localhost,1433 -U sa -P "SeaHaven_Dev_2026!" -d SeahavenIndustries -C -i scripts/spike-work-order-type.sql - -SET NOCOUNT ON; - --- --------------------------------------------------------------------------- --- 1. Colunas legadas (existem em prod/db.txt, ausentes nas migrations EF) --- --------------------------------------------------------------------------- -IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL - ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL; - -IF COL_LENGTH('workOrders', 'AvettaTask') IS NULL - ALTER TABLE workOrders ADD AvettaTask varchar(max) NULL; - -IF COL_LENGTH('workOrders', 'AssignDate') IS NULL - ALTER TABLE workOrders ADD AssignDate date NULL; - -GO - --- --------------------------------------------------------------------------- --- 2. Seed spike (somente se ainda não existir prefixo SPIKE-WO-) --- --------------------------------------------------------------------------- -IF NOT EXISTS (SELECT 1 FROM workOrders WHERE InternalWONumber LIKE 'SPIKE-WO-%') -BEGIN - DECLARE @weekStart date = CAST(GETDATE() AS date); - DECLARE @weekEnd date = DATEADD(day, 6, @weekStart); - - INSERT INTO workOrders ( - InternalWONumber, WorkerOrderTitle, SiteCode, Status, Priority, - ScheduledDate, DueDate, Trade, Problem, WorkOrderType, istemplate, CreatedDate - ) - VALUES - -- NULL WorkOrderType (5) - ('SPIKE-WO-001', 'Spike seed - null type A', 'BK5', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 3, @weekStart), 'HVAC', 'Cooling', NULL, 0, GETDATE()), - ('SPIKE-WO-002', 'Spike seed - null type B', 'BK6', 'Open', 'Low', DATEADD(day, 1, @weekStart), DATEADD(day, 5, @weekStart), 'Plumbing', 'Leak', NULL, 0, GETDATE()), - ('SPIKE-WO-003', 'Spike seed - null type C', 'BK7', 'InProgress', 'Medium', DATEADD(day, 2, @weekStart), DATEADD(day, 4, @weekStart), 'Electrical', 'Panel', NULL, 0, GETDATE()), - ('SPIKE-WO-004', 'Spike seed - null type D', 'BK8', 'Open', 'High', DATEADD(day, 3, @weekStart), DATEADD(day, 7, @weekStart), 'HVAC', 'Filter', NULL, 0, GETDATE()), - ('SPIKE-WO-005', 'Spike seed - null type E', 'BK9', 'OnHold', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 10, @weekStart), 'General', 'Inspection', NULL, 0, GETDATE()), - -- PM (5) - ('SPIKE-WO-006', 'Spike seed - PM A', 'PM1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 14, @weekStart), 'HVAC', 'Preventive Maintenance', 'PM', 0, GETDATE()), - ('SPIKE-WO-007', 'Spike seed - PM B', 'PM2', 'Open', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 14, @weekStart), 'Plumbing', 'PM Schedule', 'PM', 0, GETDATE()), - ('SPIKE-WO-008', 'Spike seed - PM C', 'PM3', 'InProgress', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 21, @weekStart), 'Electrical', 'Quarterly PM', 'PM', 0, GETDATE()), - ('SPIKE-WO-009', 'Spike seed - PM D', 'PM4', 'Open', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 30, @weekStart), 'HVAC', 'Annual PM', 'PM', 0, GETDATE()), - ('SPIKE-WO-010', 'Spike seed - PM E', 'PM5', 'Open', 'Low', DATEADD(day, 6, @weekStart), DATEADD(day, 7, @weekStart), 'General', 'PM', 'PM', 0, GETDATE()), - -- Standard (4) - ('SPIKE-WO-011', 'Spike seed - Standard A', 'ST1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', 'Standard', 0, GETDATE()), - ('SPIKE-WO-012', 'Spike seed - Standard B', 'ST2', 'InProgress', 'High', DATEADD(day, 2, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', 'Standard', 0, GETDATE()), - ('SPIKE-WO-013', 'Spike seed - Standard C', 'ST3', 'Open', 'Medium', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'Electrical', 'Repair', 'Standard', 0, GETDATE()), - ('SPIKE-WO-014', 'Spike seed - Standard D', 'ST4', 'Completed', 'Low', DATEADD(day, 1, @weekStart), DATEADD(day, 2, @weekStart), 'General', 'Repair', 'Standard', 0, GETDATE()), - -- Add-On (3) - ('SPIKE-WO-015', 'Spike seed - Add-On A', 'AO1', 'Open', 'Medium', DATEADD(day, 3, @weekStart), DATEADD(day, 9, @weekStart), 'HVAC', 'Add work', 'Add-On', 0, GETDATE()), - ('SPIKE-WO-016', 'Spike seed - Add-On B', 'AO2', 'Open', 'High', DATEADD(day, 4, @weekStart), DATEADD(day, 10, @weekStart), 'Plumbing', 'Add work', 'Add-On', 0, GETDATE()), - ('SPIKE-WO-017', 'Spike seed - Add-On C', 'AO3', 'InProgress', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 11, @weekStart), 'Electrical', 'Add work', 'Add-On', 0, GETDATE()), - -- Emergency + Corrective (3) - ('SPIKE-WO-018', 'Spike seed - Emergency', 'EM1', 'Open', 'Critical', DATEADD(day, 0, @weekStart), DATEADD(day, 1, @weekStart), 'HVAC', 'Emergency', 'Emergency', 0, GETDATE()), - ('SPIKE-WO-019', 'Spike seed - Corrective A', 'CR1', 'Open', 'High', DATEADD(day, 2, @weekStart), DATEADD(day, 4, @weekStart), 'Plumbing', 'Corrective', 'Corrective', 0, GETDATE()), - ('SPIKE-WO-020', 'Spike seed - Corrective B', 'CR2', 'Open', 'Medium', DATEADD(day, 6, @weekStart), DATEADD(day, 8, @weekStart), 'General', 'Corrective', 'Corrective', 0, GETDATE()); - - PRINT 'Seed spike: 20 work orders inseridos (prefixo SPIKE-WO-).'; -END -ELSE - PRINT 'Seed spike: já existente — pulando INSERT.'; -GO - --- --------------------------------------------------------------------------- --- 3. Queries de análise (entregável spike) --- --------------------------------------------------------------------------- -PRINT '--- Distribuição WorkOrderType (seed + existentes) ---'; -SELECT WorkOrderType, COUNT(*) AS Cnt -FROM workOrders -WHERE istemplate IS NULL OR istemplate = 0 -GROUP BY WorkOrderType -ORDER BY Cnt DESC; - -PRINT '--- % populado ---'; -SELECT - COUNT(*) AS TotalRows, - SUM(CASE WHEN WorkOrderType IS NOT NULL AND LTRIM(RTRIM(WorkOrderType)) <> '' THEN 1 ELSE 0 END) AS Populated, - CAST(100.0 * SUM(CASE WHEN WorkOrderType IS NOT NULL AND LTRIM(RTRIM(WorkOrderType)) <> '' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0) AS decimal(5,2)) AS PopulatedPct -FROM workOrders -WHERE istemplate IS NULL OR istemplate = 0; - -PRINT '--- Cruzamento Problem/Trade (hipótese derivação) ---'; -SELECT WorkOrderType, Problem, Trade, COUNT(*) AS Cnt -FROM workOrders -WHERE (istemplate IS NULL OR istemplate = 0) - AND (InternalWONumber LIKE 'SPIKE-WO-%' OR WorkOrderType IS NOT NULL) -GROUP BY WorkOrderType, Problem, Trade -ORDER BY WorkOrderType, Cnt DESC; - -PRINT '--- Template query PRODUÇÃO (rodar manualmente em prod/staging) ---'; --- SELECT WorkOrderType, COUNT(*) FROM workOrders GROUP BY WorkOrderType; --- SELECT COUNT(*) AS Total, SUM(CASE WHEN WorkOrderType IS NOT NULL THEN 1 ELSE 0 END) AS WithType FROM workOrders; - -GO diff --git a/scripts/volume-discovery.ps1 b/scripts/volume-discovery.ps1 deleted file mode 100644 index 90612a9..0000000 --- a/scripts/volume-discovery.ps1 +++ /dev/null @@ -1,61 +0,0 @@ -# Volume Discovery — Work Orders -# Executa queries do volume-discovery-report.md contra staging/prod read-only. -# -# Uso: -# $env:SQL_CONNECTION_STRING = "Server=...;Database=...;User Id=...;Password=...;TrustServerCertificate=True" -# .\scripts\volume-discovery.ps1 - -param( - [string]$ConnectionString = $env:SQL_CONNECTION_STRING -) - -function Get-SqlConnectionArgs([string]$cs) { - return @("-C", $cs) -} - -if ([string]::IsNullOrWhiteSpace($ConnectionString)) { - Write-Error "Defina SQL_CONNECTION_STRING ou passe -ConnectionString." - exit 1 -} - -$queries = @{ - TotalWorkOrders = @" -SELECT COUNT(*) AS TotalWorkOrders -FROM workOrders -WHERE IsDeleted IS NULL OR IsDeleted = 0; -"@ - P95ProxyMaxPerWeek = @" -WITH WeeklyCounts AS ( - SELECT DATEPART(iso_week, ScheduledDate) AS IsoWeek, - YEAR(ScheduledDate) AS IsoYear, - COUNT(*) AS Cnt - FROM workOrders - WHERE ScheduledDate IS NOT NULL - AND (IsDeleted IS NULL OR IsDeleted = 0) - GROUP BY DATEPART(iso_week, ScheduledDate), YEAR(ScheduledDate) -) -SELECT MAX(Cnt) AS P95ProxyMaxPerWeek, - AVG(Cnt * 1.0) AS AvgPerWeek -FROM WeeklyCounts; -"@ - UnscheduledCount = @" -SELECT COUNT(*) AS UnscheduledCount -FROM workOrders -WHERE ScheduledDate IS NULL - AND (IsDeleted IS NULL OR IsDeleted = 0) - AND Status NOT IN ('Completed', 'Complete', 'Cancelled', 'Canceled'); -"@ -} - -$connArgs = Get-SqlConnectionArgs $ConnectionString - -Write-Host "=== Volume Discovery ===" -ForegroundColor Cyan -Write-Host "" - -foreach ($name in $queries.Keys) { - Write-Host "--- $name ---" -ForegroundColor Yellow - sqlcmd @connArgs -Q $queries[$name] -W - Write-Host "" -} - -Write-Host "Preencha docs/work-orders/phase-0/volume-discovery-report.md e docs/work-orders/phase-4/search-tier-decision.md com os resultados." From cc8825ace08fa978e9e59bfad90ab72fffad4145 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:20:14 -0300 Subject: [PATCH 04/30] fix(migrations): add Phase1 BoardIndexes Designer metadata for EF discovery --- ...0701120000_Phase1_BoardIndexes.Designer.cs | 2996 +++++++++++++++++ .../20260701120000_Phase1_BoardIndexes.cs | 2 +- 2 files changed, 2997 insertions(+), 1 deletion(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs new file mode 100644 index 0000000..ba49c97 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs @@ -0,0 +1,2996 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260701120000_Phase1_BoardIndexes")] + partial class Phase1_BoardIndexes + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("WorkOrderType"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs index 23968a8..744a9fa 100644 --- a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Data.SeaHavenIndustries.Migrations { /// - public class Phase1_BoardIndexes : Migration + public partial class Phase1_BoardIndexes : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) From bdfaf1ff618083d0897ca975b4bfb539176546f1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:20:14 -0300 Subject: [PATCH 05/30] fix(migrations): add Phase1 BoardIndexes Designer metadata for EF discovery --- ...0701120000_Phase1_BoardIndexes.Designer.cs | 2996 +++++++++++++++++ .../20260701120000_Phase1_BoardIndexes.cs | 2 +- 2 files changed, 2997 insertions(+), 1 deletion(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs new file mode 100644 index 0000000..ba49c97 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs @@ -0,0 +1,2996 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260701120000_Phase1_BoardIndexes")] + partial class Phase1_BoardIndexes + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("WorkOrderType"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs index 23968a8..744a9fa 100644 --- a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Data.SeaHavenIndustries.Migrations { /// - public class Phase1_BoardIndexes : Migration + public partial class Phase1_BoardIndexes : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) From 3122cbc8f7fddb715490df272a2894447793b4c2 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:28:55 -0300 Subject: [PATCH 06/30] fix(migrations): remove undiscoverable WorkOrderType backfill migration Superseded by Phase0 (WorkOrderType as int) and Phase1 board indexes; keeping the bare Migration would either be skipped by EF or conflict if metadata were added. --- ...0_AddWorkOrderTypeAndScheduledDateIndex.cs | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs b/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs deleted file mode 100644 index e1e268e..0000000 --- a/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Data.SeaHavenIndustries.Migrations -{ - public class AddWorkOrderTypeAndScheduledDateIndex : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(""" - IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL - ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL; - """); - - migrationBuilder.Sql(""" - IF NOT EXISTS ( - SELECT 1 FROM sys.indexes - WHERE name = 'IX_workOrders_ScheduledDate' - AND object_id = OBJECT_ID('workOrders')) - BEGIN - CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate); - END - """); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(""" - IF EXISTS ( - SELECT 1 FROM sys.indexes - WHERE name = 'IX_workOrders_ScheduledDate' - AND object_id = OBJECT_ID('workOrders')) - DROP INDEX IX_workOrders_ScheduledDate ON workOrders; - """); - - migrationBuilder.Sql(""" - IF COL_LENGTH('workOrders', 'WorkOrderType') IS NOT NULL - ALTER TABLE workOrders DROP COLUMN WorkOrderType; - """); - } - } -} From ceeb0c8fa3ae7fe53ce86174e604803767032bf7 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:38:58 -0300 Subject: [PATCH 07/30] fix(work-orders): wrap board create in a transaction for atomic persistence --- .../WorkOrderBoardCreateService.cs | 54 ++++++++++++------- .../WorkOrderBoardCreateRelationalTests.cs | 53 ++++++++++++++++++ 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 77bdb27..d707f27 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -122,29 +122,47 @@ namespace SeaHaven.Services.Implementation } } - _context.workOrders.Add(workOrder); - await _context.SaveChangesAsync(); + // Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and + // audit/lock/contact rows. A transaction keeps the create atomic across both saves. + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync() + : null; - if (dispatch != null && workOrder.PrimaryDispatchId == null) + try { - workOrder.PrimaryDispatchId = dispatch.Id; - dispatch.WorkOrderId = workOrder.Id; - } + _context.workOrders.Add(workOrder); + await _context.SaveChangesAsync(); - _auditService.StageCreated(workOrder.Id, actorId, woNumber); - StageChanges(workOrder.Id, changes, actorId, dispatch); - - if (request.PocContactId.HasValue) - { - _context.WorkOrderContacts.Add(new WorkOrderContacts + if (dispatch != null && workOrder.PrimaryDispatchId == null) { - WorkorderId = workOrder.Id, - ContactId = request.PocContactId, - Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim() - }); - } + workOrder.PrimaryDispatchId = dispatch.Id; + dispatch.WorkOrderId = workOrder.Id; + } - await _context.SaveChangesAsync(); + _auditService.StageCreated(workOrder.Id, actorId, woNumber); + StageChanges(workOrder.Id, changes, actorId, dispatch); + + if (request.PocContactId.HasValue) + { + _context.WorkOrderContacts.Add(new WorkOrderContacts + { + WorkorderId = workOrder.Id, + ContactId = request.PocContactId, + Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim() + }); + } + + await _context.SaveChangesAsync(); + + if (transaction is not null) + await transaction.CommitAsync(); + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(); + throw; + } var row = await _boardService.GetBoardRowAsync(workOrder.Id); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded."); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index 2873689..0e85674 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -67,6 +67,59 @@ public class WorkOrderBoardCreateRelationalTests Assert.DoesNotContain(locks, l => l.WorkOrderId == 0); } + [Fact] + public async Task Create_WhenContactPersistenceFails_RollsBackWorkOrder() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteBoardTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + const string actorId = "actor-1"; + context.Users.Add(new ApplicationUser + { + Id = actorId, + UserName = "actor-1", + NormalizedUserName = "ACTOR-1", + Email = "actor@test.local", + NormalizedEmail = "ACTOR@TEST.LOCAL" + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var service = new WorkOrderBoardCreateService( + context, + boardData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + Description = "Should roll back", + PocContactId = 999_999 + }, actorId)); + + // Fresh context on the same connection so we observe committed state only. + await using var verify = new SqliteBoardTestDbContext(options); + Assert.Empty(await verify.workOrders.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderFieldLocks.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderContacts.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync()); + } + private sealed class SqliteBoardTestDbContext : ApplicationDbContext { public SqliteBoardTestDbContext(DbContextOptions options) From 4bd11dcf6b5a5c2e33d5ba0ead2bde2461091dd8 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 14:31:08 -0300 Subject: [PATCH 08/30] fix(work-orders): address stacked PR review feedback --- .../Controllers/WorkOrderController.cs | 27 +- .../Filters/ArgumentExceptionFilter.cs | 21 + Api.SeaHavenIndustries/Program.cs | 1 + .../Enums/LifecycleStatusSets.cs | 14 + ...OrderTypeAndScheduledDateIndex.Designer.cs | 2991 +++++++++++++++++ ...0_AddWorkOrderTypeAndScheduledDateIndex.cs | 2 +- .../Board/WorkOrderBoardConstants.cs | 7 - .../DTOs/WorkOrderBoardQuery.cs | 10 - .../DTOs/WorkOrderBoardRowData.cs | 20 - .../WorkOrderBoardDataService.cs | 3 +- .../Helpers/WorkOrderBoardFieldMutations.cs | 6 +- .../Helpers/WorkOrderBoardMutationRules.cs | 15 +- .../WorkOrderBoardUpdateService.cs | 128 +- .../WorkOrderBoardMutationRulesTests.cs | 10 + .../WorkOrderBoardUpdateServiceTests.cs | 138 + docs/work-orders/phase-1/README.md | 14 +- 16 files changed, 3308 insertions(+), 99 deletions(-) create mode 100644 Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs create mode 100644 Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.Designer.cs delete mode 100644 SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs delete mode 100644 SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs delete mode 100644 SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index df2d601..0e64af6 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.VisualBasic; @@ -92,7 +93,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("board")] public async Task GetBoard( - [FromQuery] DateOnly weekStart, + [FromQuery, BindRequired] DateOnly weekStart, [FromQuery] DateOnly? weekEnd = null, [FromQuery] List? dispatchers = null, [FromQuery] bool myWorkOrders = false, @@ -137,6 +138,14 @@ namespace Api.SeaHavenIndustries.Controllers CurrentState = ex.CurrentState }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto @@ -173,6 +182,14 @@ namespace Api.SeaHavenIndustries.Controllers Message = ex.Message }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto @@ -192,6 +209,14 @@ namespace Api.SeaHavenIndustries.Controllers var row = await _boardCancelService.CancelAsync(id, actorId); return Ok(row); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto diff --git a/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs new file mode 100644 index 0000000..0bfeabb --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Api.SeaHavenIndustries.Filters +{ + public class ArgumentExceptionFilter : IExceptionFilter + { + public void OnException(ExceptionContext context) + { + if (context.Exception is not ArgumentException ex) return; + + context.Result = new BadRequestObjectResult(new + { + status = "Error", + message = ex.Message, + code = 400 + }); + context.ExceptionHandled = true; + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..55d5a4d 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddIdentity(options => builder.Services.AddControllers(options => { options.Filters.Add(); + options.Filters.Add(); }).AddJsonOptions(options => { options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value diff --git a/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs new file mode 100644 index 0000000..d4a93bc --- /dev/null +++ b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs @@ -0,0 +1,14 @@ +namespace Data.SeaHavenIndustries.Enums +{ + /// + /// Shared terminal lifecycle statuses for EF-translatable filters and board rules. + /// + public static class LifecycleStatusSets + { + public static readonly LifecycleStatus?[] Terminal = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.Designer.cs new file mode 100644 index 0000000..1fd6e37 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.Designer.cs @@ -0,0 +1,2991 @@ +// +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("20260619150000_AddWorkOrderTypeAndScheduledDateIndex")] + partial class AddWorkOrderTypeAndScheduledDateIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs b/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs index e1e268e..45e0f3b 100644 --- a/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs +++ b/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Data.SeaHavenIndustries.Migrations { - public class AddWorkOrderTypeAndScheduledDateIndex : Migration + public partial class AddWorkOrderTypeAndScheduledDateIndex : Migration { protected override void Up(MigrationBuilder migrationBuilder) { diff --git a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs deleted file mode 100644 index bceff32..0000000 --- a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SeaHaven.DataServices.Board -{ - public static class WorkOrderBoardConstants - { - public const int ClientSideThreshold = 300; - } -} diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs deleted file mode 100644 index 6d5d1e0..0000000 --- a/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SeaHaven.DataServices.DTOs -{ - public record WorkOrderBoardQuery( - DateOnly ScheduledFrom, - DateOnly ScheduledTo, - string? Search, - IReadOnlyList? AssigneeIds, - IReadOnlyList? DbStatuses, - int? LocationId); -} diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs deleted file mode 100644 index a7eca60..0000000 --- a/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace SeaHaven.DataServices.DTOs -{ - public record WorkOrderBoardRowData( - int Id, - string? WoNumber, - string? SiteCode, - string? LocationLabel, - DateTime? ScheduledDate, - DateTime? ScheduledStart, - DateTime? DueDate, - string? WorkOrderType, - string? ServiceType, - string? AssigneeId, - string? AssigneeName, - string? VendorCompany, - string? VendorTechnician, - string? Status, - string? PocName, - string? PocPhone); -} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 05a4e82..2389fdc 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -33,8 +33,7 @@ namespace SeaHaven.DataServices.Implementation var unscheduledBase = baseQuery.Where(w => w.ScheduledDate == null - && w.LifecycleStatus != LifecycleStatus.Completed - && w.LifecycleStatus != LifecycleStatus.Canceled); + && !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus)); var scheduledTotal = await scheduledBase.CountAsync(); diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs index b0bf837..2477cb3 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs @@ -64,7 +64,11 @@ namespace SeaHaven.Services.Helpers public static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) { var changes = new List(); - if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo)) + if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.AssignTo, + workOrder.ScheduleWeekOnly)) return changes; var old = workOrder.LifecycleStatus?.ToString(); diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index 0f2be7e..3151b8b 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -4,13 +4,8 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderBoardMutationRules { - private static readonly HashSet ReadOnlyStatuses = new() - { - LifecycleStatus.Canceled, - LifecycleStatus.Completed - }; - - public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status); + public static bool IsReadOnly(LifecycleStatus? status) + => status.HasValue && LifecycleStatusSets.Terminal.Contains(status); public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue) => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; @@ -18,10 +13,12 @@ namespace SeaHaven.Services.Helpers public static bool ShouldAutoSchedule( LifecycleStatus? status, DateTime? scheduledDate, - string? assignTo) + string? assignTo, + bool? scheduleWeekOnly = null) => status == LifecycleStatus.Incomplete && scheduledDate.HasValue - && !string.IsNullOrWhiteSpace(assignTo); + && !string.IsNullOrWhiteSpace(assignTo) + && scheduleWeekOnly != true; public static bool IsReschedule(DateTime? previousDate, DateTime? newDate) => previousDate.HasValue diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 98239bb..89108ea 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -30,8 +30,15 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardPatchRequestDto request, string? actorId) { - if (string.IsNullOrWhiteSpace(request.Field)) - throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); + // Intermediate SaveChanges (e.g. new dispatch identity) must stay atomic with the final patch save. + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync() + : null; + + try + { + if (string.IsNullOrWhiteSpace(request.Field)) + throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); var field = request.Field.Trim(); var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field); @@ -91,48 +98,68 @@ namespace SeaHaven.Services.Implementation var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField); changes = changes.Where(c => c.HasChanged).ToList(); - if (changes.Count == 0 && !_context.ChangeTracker.HasChanges()) - { - var unchanged = await LoadBoardRowAsync(workOrderId); - return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - } - - if (changes.Count == 0) - { - await _context.SaveChangesAsync(); - var persisted = await LoadBoardRowAsync(workOrderId); - return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - } - - foreach (var change in changes) - { - switch (change.Action) + if (changes.Count == 0 && !_context.ChangeTracker.HasChanges()) { - case AuditActionType.StatusChanged: - _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); - break; - case AuditActionType.AssignmentChanged: - _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); - break; - default: - _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId); - break; + var unchanged = await LoadBoardRowAsync(workOrderId); + if (transaction is not null) + await transaction.CommitAsync(); + return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } - } - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - _context.ChangeTracker.Clear(); - var currentState = await LoadBoardRowAsync(workOrderId); - throw new WorkOrderBoardConcurrencyException(currentState); - } + if (changes.Count == 0) + { + await _context.SaveChangesAsync(); + if (transaction is not null) + await transaction.CommitAsync(); + var persisted = await LoadBoardRowAsync(workOrderId); + return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } - var row = await LoadBoardRowAsync(workOrderId); - return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + foreach (var change in changes) + { + switch (change.Action) + { + case AuditActionType.StatusChanged: + _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); + break; + case AuditActionType.AssignmentChanged: + _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); + break; + default: + _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId); + break; + } + } + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (transaction is not null) + await transaction.RollbackAsync(); + _context.ChangeTracker.Clear(); + var currentState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(currentState); + } + + if (transaction is not null) + await transaction.CommitAsync(); + + var row = await LoadBoardRowAsync(workOrderId); + return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + catch (WorkOrderBoardConcurrencyException) + { + throw; + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(); + throw; + } } private async Task> ApplyFieldMutationAsync( @@ -153,7 +180,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), WorkOrderBoardFieldNames.TargetWeek => new List { ApplyTargetWeek(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, - WorkOrderBoardFieldNames.VendorId => new List { ApplyVendorId(dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, @@ -187,6 +214,8 @@ namespace SeaHaven.Services.Implementation if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId."); + await EnsureVendorExistsAsync(vendorIdForCreate.Value); + var created = new Dispatch { WorkOrderId = workOrder.Id, @@ -200,6 +229,13 @@ namespace SeaHaven.Services.Implementation return created; } + private async Task EnsureVendorExistsAsync(int vendorId) + { + var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true); + if (!vendorExists) + throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); + } + private async Task ApplyWoNumber(WorkOrder workOrder, string? value, string auditField) { if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error)) @@ -384,12 +420,14 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, parsed.ToString()); } - private static FieldChange ApplyVendorId(Dispatch dispatch, string? value, string auditField) + private async Task ApplyVendorIdAsync(Dispatch dispatch, string? value, string auditField) { var vendorId = ParseVendorIdHint(value); if (!vendorId.HasValue) throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer."); + await EnsureVendorExistsAsync(vendorId.Value); + var old = dispatch.VendorId.ToString(); if (dispatch.VendorId == vendorId.Value) return FieldChange.Unchanged(auditField, dispatch.Id); @@ -459,7 +497,11 @@ namespace SeaHaven.Services.Implementation private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) { var changes = new List(); - if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo)) + if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.AssignTo, + workOrder.ScheduleWeekOnly)) return changes; var old = workOrder.LifecycleStatus?.ToString(); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 34509ce..12d600b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -42,6 +42,16 @@ public class WorkOrderBoardMutationRulesTests "dispatcher-1")); } + [Fact] + public void ShouldAutoSchedule_FalseWhenWeekOnly() + { + Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Incomplete, + new DateTime(2026, 6, 25), + "dispatcher-1", + scheduleWeekOnly: true)); + } + [Fact] public void IsReschedule_WhenPreviousDateExistsAndChanges() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 59b180c..f8270d1 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -224,4 +224,142 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("DuplicateWoNumber", ex.Code); } + + [Fact] + public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = new DateTime(2026, 6, 25), + ScheduleWeekOnly = true, + TargetWeek = new DateOnly(2026, 6, 22), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AssignTo, + Value = "user-a", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); + Assert.Equal("user-a", result.DispatcherId); + } + + [Fact] + public async Task PatchField_ApptTime_UpdatesScheduledStart() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "09:00-10:00", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + Assert.Contains("09:00", result.ApptTime); + var reloaded = await context.workOrders.FindAsync(1); + Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded!.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd); + } + + [Fact] + public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptDate, + Value = "2026-06-26", + WorkOrderVersion = ToVersion(wo), + PrimaryDispatchId = 10 + }, "actor-1")); + + Assert.Equal("DispatchVersionRequired", ex.Code); + } + + [Fact] + public async Task PatchField_VendorIdNotFound_Throws() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "999", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1")); + + Assert.Equal("VendorNotFound", ex.Code); + } } diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md index 5442c49..e1515a5 100644 --- a/docs/work-orders/phase-1/README.md +++ b/docs/work-orders/phase-1/README.md @@ -17,10 +17,10 @@ Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada. | Param | Obrigatório | Descrição | |-------|-------------|-----------| -| `weekStart` | Sim | Segunda-feira da semana (`YYYY-MM-DD`) | -| `weekEnd` | Não | Sexta-feira (default: `weekStart + 4 dias`) | -| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos | -| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` | +| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. | +| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. | +| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. | +| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). | | `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) | | `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) | @@ -45,10 +45,14 @@ curl -H "Authorization: Bearer " \ - `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search) - `counts.returned` — WOs agendadas após aplicar search +- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo) +- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior) ### `GET /api/workorders/lookups/dispatchers` -Lista dispatchers para filtros do board. +Lista opções para o filtro/dropdown de assignee do board. + +**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito. ```json [ From 76db2b5a37d1741dd0137b49386fb1250bb8985d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 14:46:31 -0300 Subject: [PATCH 09/30] fix(work-orders): address PR14 phase-2 review feedback --- .../Board/WorkOrderBoardConstants.cs | 11 -- .../Helpers/WorkOrderBoardMutationRules.cs | 15 +- .../WorkOrderBoardUpdateService.cs | 51 ++++- .../WorkOrderBoardMutationRulesTests.cs | 10 + .../WorkOrderBoardUpdateServiceTests.cs | 178 ++++++++++++++++++ 5 files changed, 244 insertions(+), 21 deletions(-) delete mode 100644 SeaHaven.Services/Board/WorkOrderBoardConstants.cs diff --git a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs deleted file mode 100644 index 78d1ab5..0000000 --- a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SeaHaven.Services.Board -{ - /// - /// Shared board constants for Phase 2+ features. Not referenced in Phase 1. - /// - public static class WorkOrderBoardConstants - { - public const int MaxWindowDays = 90; - public const int ClientSideThreshold = 300; - } -} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index 0f2be7e..456ff91 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -4,22 +4,25 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderBoardMutationRules { - private static readonly HashSet ReadOnlyStatuses = new() - { - LifecycleStatus.Canceled, - LifecycleStatus.Completed - }; + private static readonly HashSet ReadOnlyStatuses = + LifecycleStatusSets.Terminal.ToHashSet(); public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status); public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue) => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; + /// + /// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled. + /// Week-only targets do not auto-schedule (same as ). + /// public static bool ShouldAutoSchedule( LifecycleStatus? status, DateTime? scheduledDate, - string? assignTo) + string? assignTo, + bool? scheduleWeekOnly = null) => status == LifecycleStatus.Incomplete + && scheduleWeekOnly != true && scheduledDate.HasValue && !string.IsNullOrWhiteSpace(assignTo); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 1eb1ce7..c1409b6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -30,6 +30,13 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardPatchRequestDto request, string? actorId) { + // Intermediate SaveChanges (e.g. new dispatch identity) must stay atomic with the final patch save. + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync() + : null; + + try + { if (string.IsNullOrWhiteSpace(request.Field)) throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); @@ -94,12 +101,16 @@ namespace SeaHaven.Services.Implementation if (changes.Count == 0 && !_context.ChangeTracker.HasChanges()) { var unchanged = await LoadBoardRowAsync(workOrderId); + if (transaction is not null) + await transaction.CommitAsync(); return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } if (changes.Count == 0) { await _context.SaveChangesAsync(); + if (transaction is not null) + await transaction.CommitAsync(); var persisted = await LoadBoardRowAsync(workOrderId); return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } @@ -127,13 +138,29 @@ namespace SeaHaven.Services.Implementation } catch (DbUpdateConcurrencyException) { + if (transaction is not null) + await transaction.RollbackAsync(); _context.ChangeTracker.Clear(); var currentState = await LoadBoardRowAsync(workOrderId); throw new WorkOrderBoardConcurrencyException(currentState); } + if (transaction is not null) + await transaction.CommitAsync(); + var row = await LoadBoardRowAsync(workOrderId); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + catch (WorkOrderBoardConcurrencyException) + { + throw; + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(); + throw; + } } private async Task> ApplyFieldMutationAsync( @@ -154,7 +181,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), WorkOrderBoardFieldNames.TargetWeek => new List { ApplyTargetWeek(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, - WorkOrderBoardFieldNames.VendorId => new List { ApplyVendorId(dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, @@ -188,6 +215,8 @@ namespace SeaHaven.Services.Implementation if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId."); + await EnsureVendorExistsAsync(vendorIdForCreate.Value); + var created = new Dispatch { WorkOrderId = workOrder.Id, @@ -196,10 +225,18 @@ namespace SeaHaven.Services.Implementation CreatedDate = DateTime.UtcNow }; _context.Dispatches.Add(created); - workOrder.PrimaryDispatch = created; + await _context.SaveChangesAsync(); + workOrder.PrimaryDispatchId = created.Id; return created; } + private async Task EnsureVendorExistsAsync(int vendorId) + { + var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true); + if (!vendorExists) + throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); + } + private async Task ApplyWoNumber(WorkOrder workOrder, string? value, string auditField) { if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error)) @@ -384,12 +421,14 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, parsed.ToString()); } - private static FieldChange ApplyVendorId(Dispatch dispatch, string? value, string auditField) + private async Task ApplyVendorIdAsync(Dispatch dispatch, string? value, string auditField) { var vendorId = ParseVendorIdHint(value); if (!vendorId.HasValue) throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer."); + await EnsureVendorExistsAsync(vendorId.Value); + var old = dispatch.VendorId.ToString(); if (dispatch.VendorId == vendorId.Value) return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); @@ -461,7 +500,11 @@ namespace SeaHaven.Services.Implementation private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) { var changes = new List(); - if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo)) + if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.AssignTo, + workOrder.ScheduleWeekOnly)) return changes; var old = workOrder.LifecycleStatus?.ToString(); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 34509ce..933aa80 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -42,6 +42,16 @@ public class WorkOrderBoardMutationRulesTests "dispatcher-1")); } + [Fact] + public void ShouldAutoSchedule_FalseWhenScheduleWeekOnly() + { + Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Incomplete, + new DateTime(2026, 6, 25), + "dispatcher-1", + scheduleWeekOnly: true)); + } + [Fact] public void IsReschedule_WhenPreviousDateExistsAndChanges() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index cb2b73a..a04425c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -274,4 +274,182 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("DuplicateWoNumber", ex.Code); } + + [Fact] + public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = new DateTime(2026, 6, 25), + ScheduleWeekOnly = true, + TargetWeek = new DateOnly(2026, 6, 22), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.AssignTo, + Value = "user-a", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); + Assert.Equal("user-a", result.DispatcherId); + } + + [Fact] + public async Task PatchField_ApptTime_UpdatesScheduledStart() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "09:00-10:00", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + Assert.Contains("09:00", result.ApptTime); + var reloaded = await context.workOrders.FindAsync(1); + Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded!.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd); + } + + [Fact] + public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "-30", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + var reloaded = await context.workOrders.FindAsync(1); + Assert.NotNull(reloaded!.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25), reloaded.ScheduledEnd); + Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart); + } + + [Fact] + public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptDate, + Value = "2026-06-26", + WorkOrderVersion = ToVersion(wo), + PrimaryDispatchId = 10 + }, "actor-1")); + + Assert.Equal("DispatchVersionRequired", ex.Code); + } + + [Fact] + public async Task PatchField_VendorIdNotFound_Throws() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "999", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1")); + + Assert.Equal("VendorNotFound", ex.Code); + } } From f3d2a5cfe738cf677a3f1af2b3c90beddef2f7ca Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 14:39:47 -0300 Subject: [PATCH 10/30] fix(work-orders): address PR13 phase-1 review feedback --- .../Controllers/WorkOrderController.cs | 3 +- .../Filters/ArgumentExceptionFilter.cs | 21 +++ Api.SeaHavenIndustries/Program.cs | 1 + .../Enums/LifecycleStatusSets.cs | 14 ++ .../WorkOrderBoardDataService.cs | 3 +- docs/work-orders/phase-1/README.md | 159 ++++++++++++++++++ 6 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs create mode 100644 Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs create mode 100644 docs/work-orders/phase-1/README.md diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 1989d84..f72501c 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.VisualBasic; @@ -83,7 +84,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("board")] public async Task GetBoard( - [FromQuery] DateOnly weekStart, + [FromQuery, BindRequired] DateOnly weekStart, [FromQuery] DateOnly? weekEnd = null, [FromQuery] List? dispatchers = null, [FromQuery] bool myWorkOrders = false, diff --git a/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs new file mode 100644 index 0000000..0bfeabb --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Api.SeaHavenIndustries.Filters +{ + public class ArgumentExceptionFilter : IExceptionFilter + { + public void OnException(ExceptionContext context) + { + if (context.Exception is not ArgumentException ex) return; + + context.Result = new BadRequestObjectResult(new + { + status = "Error", + message = ex.Message, + code = 400 + }); + context.ExceptionHandled = true; + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..55d5a4d 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddIdentity(options => builder.Services.AddControllers(options => { options.Filters.Add(); + options.Filters.Add(); }).AddJsonOptions(options => { options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value diff --git a/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs new file mode 100644 index 0000000..d4a93bc --- /dev/null +++ b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs @@ -0,0 +1,14 @@ +namespace Data.SeaHavenIndustries.Enums +{ + /// + /// Shared terminal lifecycle statuses for EF-translatable filters and board rules. + /// + public static class LifecycleStatusSets + { + public static readonly LifecycleStatus?[] Terminal = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 8fd0422..be23363 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -33,8 +33,7 @@ namespace SeaHaven.DataServices.Implementation var unscheduledBase = baseQuery.Where(w => w.ScheduledDate == null - && w.LifecycleStatus != LifecycleStatus.Completed - && w.LifecycleStatus != LifecycleStatus.Canceled); + && !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus)); var scheduledTotal = await scheduledBase.CountAsync(); diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md new file mode 100644 index 0000000..660bfb3 --- /dev/null +++ b/docs/work-orders/phase-1/README.md @@ -0,0 +1,159 @@ +# Fase 1 — Weekly Board (leitura) + +**Programa:** Work Orders Board +**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais. + +--- + +## Endpoints + +### `GET /api/workorders/board` + +Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada. + +**Autenticação:** Bearer JWT (`[Authorize]`) + +**Query params:** + +| Param | Obrigatório | Descrição | +|-------|-------------|-----------| +| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. | +| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. | +| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. | +| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). | +| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) | +| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) | + +**Exemplo:** + +```bash +curl -H "Authorization: Bearer " \ + "https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true" +``` + +**Response:** + +```json +{ + "weekStart": "2026-06-22", + "weekEnd": "2026-06-26", + "counts": { "returned": 42, "total": 58 }, + "unscheduled": [ /* WorkOrderBoardRowDto[] */ ], + "scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ] +} +``` + +- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search) +- `counts.returned` — WOs agendadas após aplicar search +- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo) +- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior) + +### `GET /api/workorders/lookups/dispatchers` + +Lista opções para o filtro/dropdown de assignee do board. + +**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito. + +```json +[ + { "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" } +] +``` + +--- + +## Matriz coluna → campo API + +| Coluna board | Campos response | +|--------------|-----------------| +| WO# | `woNumber`, `rescheduleCount`, `carriedOver` | +| Type | `workOrderType`, `isPastDue` | +| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` | +| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` | +| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` | +| Due | `dueDate` | +| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` | +| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` | +| Appt | `apptDate`, `apptTime` | +| Past Due | `isPastDue` (derivado on-read) | +| Carried | `carriedOver` | +| Reschedule | `rescheduleCount` | +| Doc | `docStatus` | +| Actions | FE only | + +--- + +## Regras de derivação + +### isPastDue + +``` +ScheduledDate < UTC hoje +AND LifecycleStatus NOT IN (Complete, Canceled, Closed) +``` + +Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` + +### Vendor / Appt + +Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)). + +### Janela semanal + +WO entra em **scheduled** se: + +- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou** +- `ScheduleWeekOnly == true` e `TargetWeek == weekStart` + +**Unscheduled:** `ScheduledDate == null` e status não terminal (`LifecycleStatusSets.Terminal`). + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | +| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` | +| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` | +| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` | +| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) | +| Migration | `Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs` | +| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` | + +--- + +## Gates de aceite + +### Desenvolvimento (concluído) + +- [x] Endpoint `GET board` — semana + Unscheduled +- [x] Projeção 12/14 colunas de dados +- [x] `isPastDue` derivado on-read +- [x] Vendor via dispatch primário +- [x] Filtros dispatcher / My WOs / tipo / search +- [x] Contador X of Y +- [x] Índices Tier S +- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total) + +### Staging / produção (pendente) + +- [ ] Sign-off CTO/PO (herda gates Fase 0) +- [ ] Dry-run migration Phase0 + Phase1 em staging +- [ ] Tier volume confirmado em staging/prod +- [ ] Smoke checklist Blazor / Sync / Portal +- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados) +- [ ] Latência aceitável com volume real (Tier S: < 25k WOs) +- [ ] Feature flag SHOC board em staging + +--- + +## Coexistência + +Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas. + +--- + +## Próximo passo + +**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular). From 61b4c8433710d17c4641e409c114556d479a60ad Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:31:27 -0300 Subject: [PATCH 11/30] fix(work-orders): lock ScheduledStart/End when patching apptTime Align board apptTime field locks with SyncFieldMergePolicy field names so sync cannot overwrite a manual appointment edit. Also add shared LifecycleStatusSets so the Phase 2 branch compiles. --- .../Constants/WorkOrderFieldNames.cs | 1 + .../Helpers/LifecycleStatusSets.cs | 20 ++++++ .../Helpers/WorkOrderBoardMutationRules.cs | 2 +- .../Implementation/SyncFieldMergePolicy.cs | 5 ++ .../WorkOrderBoardUpdateService.cs | 24 ++++--- .../WorkOrderBoardUpdateServiceTests.cs | 66 +++++++++++++++++++ 6 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 SeaHaven.Services/Helpers/LifecycleStatusSets.cs diff --git a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs index 967e102..f97a394 100644 --- a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs +++ b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs @@ -5,6 +5,7 @@ namespace SeaHaven.Services.Constants public const string LifecycleStatus = nameof(LifecycleStatus); public const string AssignTo = nameof(AssignTo); public const string ScheduledDate = nameof(ScheduledDate); + public const string ScheduledStart = nameof(ScheduledStart); public const string ScheduledEnd = nameof(ScheduledEnd); public const string TargetWeek = nameof(TargetWeek); public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly); diff --git a/SeaHaven.Services/Helpers/LifecycleStatusSets.cs b/SeaHaven.Services/Helpers/LifecycleStatusSets.cs new file mode 100644 index 0000000..5427ea6 --- /dev/null +++ b/SeaHaven.Services/Helpers/LifecycleStatusSets.cs @@ -0,0 +1,20 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Shared terminal lifecycle statuses for board filters, read-only checks, and jobs. + /// Keep this as the single source of truth (EF expressions should copy these values inline). + /// + public static class LifecycleStatusSets + { + public static readonly LifecycleStatus[] Terminal = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + + public static bool IsTerminal(LifecycleStatus? status) + => status is LifecycleStatus.Completed or LifecycleStatus.Canceled; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index 456ff91..3067947 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -5,7 +5,7 @@ namespace SeaHaven.Services.Helpers public static class WorkOrderBoardMutationRules { private static readonly HashSet ReadOnlyStatuses = - LifecycleStatusSets.Terminal.ToHashSet(); + LifecycleStatusSets.Terminal.Cast().ToHashSet(); public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status); diff --git a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs index eefdcbd..a14eed8 100644 --- a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs +++ b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs @@ -12,6 +12,7 @@ namespace SeaHaven.Services.Implementation WorkOrderFieldNames.LifecycleStatus, WorkOrderFieldNames.AssignTo, WorkOrderFieldNames.ScheduledDate, + WorkOrderFieldNames.ScheduledStart, WorkOrderFieldNames.ScheduledEnd, WorkOrderFieldNames.TargetWeek, WorkOrderFieldNames.ScheduleWeekOnly, @@ -68,6 +69,7 @@ namespace SeaHaven.Services.Implementation WorkOrderFieldNames.Status => wo.Status, WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"), WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"), + WorkOrderFieldNames.ScheduledStart => wo.ScheduledStart?.ToString("O"), WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"), WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"), WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(), @@ -105,6 +107,9 @@ namespace SeaHaven.Services.Implementation case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate): wo.ScheduledDate = scheduledDate; return true; + case WorkOrderFieldNames.ScheduledStart when TryParseDateTime(incomingValue, out var scheduledStart): + wo.ScheduledStart = scheduledStart; + return true; case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd): wo.ScheduledEnd = scheduledEnd; return true; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index c1409b6..98002a2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; @@ -183,7 +184,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, WorkOrderBoardFieldNames.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, - WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") }; @@ -456,9 +457,9 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch)); } - // apptTime is stored on WorkOrder (ScheduledStart/End) but requires a primary dispatch - // for concurrency (dispatchVersion) and audit context; apptDate lives on Dispatch.ScheduledDate. - private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField) + // apptTime is stored on WorkOrder (ScheduledStart/End). Lock those sync field names so + // SyncFieldMergePolicy honors the manual edit (not a synthetic "ApptTime" lock). + private static List ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value) { if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error)) throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime."); @@ -466,19 +467,24 @@ namespace SeaHaven.Services.Implementation var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate; var oldStart = FormatDateTime(workOrder.ScheduledStart); var oldEnd = FormatDateTime(workOrder.ScheduledEnd); + var dispatchId = ResolveDispatchIdForAudit(dispatch); workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start); workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end); var newStart = FormatDateTime(workOrder.ScheduledStart); var newEnd = FormatDateTime(workOrder.ScheduledEnd); - var oldCombined = FormatApptRange(oldStart, oldEnd); - var newCombined = FormatApptRange(newStart, newEnd); + var changes = new List(); - if (oldCombined == newCombined) - return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); + if (oldStart != newStart) + changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledStart, oldStart, newStart, dispatchId)); + if (oldEnd != newEnd) + changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledEnd, oldEnd, newEnd, dispatchId)); - return FieldChange.ForField(auditField, oldCombined, newCombined, ResolveDispatchIdForAudit(dispatch)); + if (changes.Count == 0) + changes.Add(FieldChange.Unchanged(WorkOrderFieldNames.ScheduledStart, dispatchId)); + + return changes; } private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index a04425c..f0b4047 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -2,10 +2,12 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; @@ -382,6 +384,70 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart); } + [Fact] + public async Task PatchField_ApptTime_LocksScheduledStartAndEnd_ForSync() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "09:00-10:00", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + var lockNames = await context.WorkOrderFieldLocks + .Where(l => l.WorkOrderId == 1) + .Select(l => l.FieldName) + .ToListAsync(); + Assert.Contains(WorkOrderFieldNames.ScheduledStart, lockNames); + Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames); + Assert.DoesNotContain("ApptTime", lockNames); + + var locks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, locks); + var policy = new SyncFieldMergePolicy(locks); + var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1); + var syncContext = new WorkOrderSyncContext + { + WorkOrder = reloaded, + FieldLocks = locks, + Audit = audit + }; + + var appliedStart = await policy.TryApplyAsync( + syncContext, WorkOrderFieldNames.ScheduledStart, "2026-06-25T08:00:00"); + var appliedEnd = await policy.TryApplyAsync( + syncContext, WorkOrderFieldNames.ScheduledEnd, "2026-06-25T11:00:00"); + + Assert.False(appliedStart); + Assert.False(appliedEnd); + Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd); + } + [Fact] public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired() { From 75969a1e3da8de1fd6a8f5209164c38435be2424 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:33:24 -0300 Subject: [PATCH 12/30] docs(work-orders): clarify board filter precedence and dispatcher lookup Document myWorkOrders overriding dispatchers, weekend dayGroup null contract, and that dispatcher lookups currently return all users. Map service ArgumentException to 400 for consistency. --- .../Controllers/WorkOrderController.cs | 39 +++++++++++++------ .../Helpers/WorkOrderBoardQueryFilters.cs | 5 +++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index f72501c..f3d5ca9 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -82,6 +82,12 @@ namespace Api.SeaHavenIndustries.Controllers _workOrderBoardService = workOrderBoardService; } + /// + /// Weekly board payload. + /// When is true, the authenticated user filter wins and + /// is ignored (not AND-combined). + /// Weekend WOs in a Mon–Sun window may have dayGroup = null; FE should handle that. + /// [HttpGet("board")] public async Task GetBoard( [FromQuery, BindRequired] DateOnly weekStart, @@ -96,21 +102,32 @@ namespace Api.SeaHavenIndustries.Controllers if (weekValidationError != null) return BadRequest(weekValidationError); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var query = new WorkOrderBoardQueryDto + try { - WeekStart = weekStart, - WeekEnd = weekEnd, - Dispatchers = dispatchers, - MyWorkOrders = myWorkOrders, - Types = types, - Search = search - }; + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var query = new WorkOrderBoardQueryDto + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Dispatchers = dispatchers, + MyWorkOrders = myWorkOrders, + Types = types, + Search = search + }; - var result = await _workOrderBoardService.GetBoardAsync(query, userId); - return Ok(result); + var result = await _workOrderBoardService.GetBoardAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } } + /// + /// Dispatcher dropdown options. Currently returns all non-deleted users + /// (role-filtered assignee list deferred until roles settle). + /// [HttpGet("lookups/dispatchers")] public async Task GetDispatcherLookups() { diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index c57fc5f..d13b4d3 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -8,6 +8,11 @@ namespace SeaHaven.DataServices.Helpers public static IQueryable ApplyBaseScope(IQueryable query) => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + /// + /// Applies assignee filters. When is true and + /// is set, that takes precedence and + /// is ignored. + /// public static IQueryable ApplyDispatcherFilter( IQueryable query, IReadOnlyList? dispatchers, From 1162c68596ac5f276b69604734e499dfc8a29c85 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 12:55:47 -0300 Subject: [PATCH 13/30] feat(vendors): add directory filters and details API (#25) * feat(vendors): add directory filters and details API * fix(vendors): preserve omitted status * fix(vendors): align facet filtering * fix(vendors): address directory review findings --- .../VendorControllerTests.cs | 169 +++++++++++ .../VendorDataServiceTests.cs | 269 ++++++++++++++++++ .../VendorServiceTests.cs | 213 ++++++++++++++ .../Controllers/VendorController.cs | 153 +++++++++- Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs | 5 +- ...0260720220000_AddVendorDirectoryDetails.cs | 41 +++ .../ApplicationDbContextModelSnapshot.cs | 9 + Data.SeaHavenIndustries/Models/Vendor.cs | 7 + .../Implementation/VendorDataService.cs | 234 +++++++++++++-- .../Interfaces/IVendorDataService.cs | 10 +- .../Properties/AssemblyInfo.cs | 3 + SeaHaven.Services/DTOs/VendorDTOs.cs | 19 ++ .../Implementation/VendorService.cs | 70 ++++- .../Interfaces/IVendorService.cs | 10 +- .../Validation/VendorValidation.cs | 48 ++++ 15 files changed, 1218 insertions(+), 42 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/VendorControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorServiceTests.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs create mode 100644 SeaHaven.DataServices/Properties/AssemblyInfo.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs new file mode 100644 index 0000000..2440734 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -0,0 +1,169 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using System.Text.Json; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorControllerTests +{ + private static VendorController NewController(Mock service) + { + var controller = new VendorController( + service.Object, + db: null!, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "42") + }, "Test")) + } + }; + + return controller; + } + + [Fact] + public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus() + { + var service = new Mock(); + service.Setup(x => x.UpdateVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new EditVendor_DTO + { + Id = 8, + CompanyName = "Updated Name" + }; + + var result = await controller.Update(8, model); + + result.Should().BeOfType(); + service.Verify(x => x.UpdateVendorAsync( + 8, + It.Is(dto => dto.IsActive == null), + "42"), Times.Once); + } + + [Fact] + public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged() + { + var service = new Mock(); + service.Setup(x => x.UpdateVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new EditVendor_DTO + { + Id = 8, + CompanyName = "Updated Name", + IsActive = false + }; + + await controller.Update(8, model); + + service.Verify(x => x.UpdateVendorAsync( + 8, + It.Is(dto => dto.IsActive == false), + "42"), Times.Once); + } + + [Fact] + public async Task Create_WithoutIsActive_DefaultsToActive() + { + var service = new Mock(); + service.Setup(x => x.CreateVendorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new Vendor_DTO + { + CompanyName = "New Co" + }; + + await controller.Create(model); + + service.Verify(x => x.CreateVendorAsync( + It.Is(dto => dto.IsActive == true), + "42"), Times.Once); + } + + [Theory] + [InlineData(true, "Active Co", "Inactive Co")] + [InlineData(false, "Inactive Co", "Active Co")] + public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors( + bool isActive, + string expected, + string excluded) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + context.Vendors.AddRange( + new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); + await context.SaveChangesAsync(); + + var controller = new VendorController( + Mock.Of(), + context, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + var result = await controller.GetFacets(isActive); + var payload = JsonSerializer.Serialize( + result.Should().BeOfType().Subject.Value); + + payload.Should().Contain(expected); + payload.Should().NotContain(excluded); + } + + [Fact] + public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + context.Vendors.AddRange( + new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); + await context.SaveChangesAsync(); + + var controller = new VendorController( + Mock.Of(), + context, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + var result = await controller.GetFacets(); + var payload = JsonSerializer.Serialize( + result.Should().BeOfType().Subject.Value); + + payload.Should().Contain("Active Co"); + payload.Should().Contain("Inactive Co"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs new file mode 100644 index 0000000..31c4940 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -0,0 +1,269 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorDataServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ApplicationDbContext NewSqlServerContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=(local);Database=TranslationOnly;Trusted_Connection=True;TrustServerCertificate=True") + .Options; + return new ApplicationDbContext(options); + } + + private static Vendor VendorWithJobs( + string company, + string city, + string state, + string trades, + int jobCount, + bool isActive = true) + { + return new Vendor + { + CompanyName = company, + ContactName = $"{company} Technician", + City = city, + State = state, + TradeSpecialties = trades, + IsActive = isActive, + IsDeleted = false, + Dispatches = Enumerable.Range(1, jobCount) + .Select(workOrderId => new Dispatch { WorkOrderId = workOrderId }) + .ToList() + }; + } + + private static async Task SeedBoundaryVendors(ApplicationDbContext context) + { + context.Vendors.AddRange( + VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49), + VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50), + VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100), + VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150)); + await context.SaveChangesAsync(); + } + + [Theory] + [InlineData("under-50", "Under Co")] + [InlineData("50-99", "Fifty Co")] + [InlineData("100-149", "Hundred Co")] + [InlineData("150-plus", "One Fifty Co")] + public async Task GetPagedAsync_AppliesExactJobBucketBoundaries( + string bucket, + string expectedCompany) + { + await using var context = NewContext(); + await SeedBoundaryVendors(context); + var service = new VendorDataService(context); + + var (items, totalCount) = await service.GetPagedAsync( + 1, + 12, + isActive: true, + jobBuckets: new[] { bucket }); + + totalCount.Should().Be(1); + items.Single().CompanyName.Should().Be(expectedCompany); + } + + [Fact] + public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets() + { + await using var context = NewContext(); + await SeedBoundaryVendors(context); + var service = new VendorDataService(context); + + var (items, totalCount) = await service.GetPagedAsync( + 1, + 12, + isActive: true, + companies: new[] { "Under Co", "Hundred Co" }, + trades: new[] { "HVAC", "Plumbing" }, + locations: new[] { "Chicago, IL" }, + jobBuckets: new[] { "under-50", "100-149" }); + + totalCount.Should().Be(1); + items.Single().CompanyName.Should().Be("Under Co"); + } + + [Fact] + public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory() + { + await using var context = NewContext(); + context.Vendors.AddRange( + VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1), + VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false)); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var active = await service.GetPagedAsync(1, 1, isActive: true); + var inactive = await service.GetPagedAsync(1, 1, isActive: false); + + active.TotalCount.Should().Be(1); + inactive.TotalCount.Should().Be(1); + active.Items.Single().CompanyName.Should().Be("Active Co"); + inactive.Items.Single().CompanyName.Should().Be("Inactive Co"); + } + + [Fact] + public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" }); + cityOnly.TotalCount.Should().Be(1); + cityOnly.Items.Single().CompanyName.Should().Be("CityOnly"); + + var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" }); + stateOnly.TotalCount.Should().Be(1); + stateOnly.Items.Single().CompanyName.Should().Be("StateOnly"); + + var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" }); + full.TotalCount.Should().Be(1); + full.Items.Single().CompanyName.Should().Be("FullLoc"); + } + + [Fact] + public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" }); + hvac.TotalCount.Should().Be(1); + hvac.Items.Single().CompanyName.Should().Be("HvacOnly"); + + var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" }); + hvacControls.TotalCount.Should().Be(1); + hvacControls.Items.Single().CompanyName.Should().Be("HvacControls"); + + var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" }); + plumb.TotalCount.Should().Be(0); + plumb.Items.Should().BeEmpty(); + + var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" }); + plumbing.TotalCount.Should().Be(1); + plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo"); + } + + [Fact] + public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" }); + plumbing.TotalCount.Should().Be(2); + plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle"); + + var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" }); + roofing.TotalCount.Should().Be(1); + roofing.Items.Single().CompanyName.Should().Be("Middle"); + } + + [Fact] + public async Task GetPagedAsync_CountsDistinctDirectAndLinkedWorkOrders() + { + await using var context = NewContext(); + var vendor = new Vendor + { + CompanyName = "History Co", + IsActive = true, + IsDeleted = false, + Dispatches = new List + { + new() + { + WorkOrderId = 100, + DispatchWorkOrders = new List + { + new() { WorkOrderId = 100 }, + new() { WorkOrderId = 101 } + } + }, + new() { WorkOrderId = 101 } + } + }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var result = await service.GetPagedAsync(1, 12, isActive: true); + + result.Items.Single().TotalJobs.Should().Be(2); + } + + [Fact] + public async Task GetByIdAsync_ReturnsTrackedVendorWithoutLoadingDispatchGraph() + { + await using var context = NewContext(); + var vendor = VendorWithJobs("Lean Co", "Chicago", "IL", "Plumbing", 2); + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var service = new VendorDataService(context); + var result = await service.GetByIdAsync(vendor.Id); + + result.Should().NotBeNull(); + context.Entry(result!).State.Should().Be(EntityState.Unchanged); + context.Entry(result!).Collection(nameof(Vendor.Dispatches)).IsLoaded.Should().BeFalse(); + result!.Dispatches.Should().BeNull(); + } + + [Theory] + [InlineData("trade")] + [InlineData("location")] + [InlineData("job-bucket")] + public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter) + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var query = filter switch + { + "trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }), + "location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }), + _ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" }) + }; + + var sql = query.ToQueryString(); + + sql.Should().Contain("SELECT"); + if (filter == "trade") + sql.Should().Contain("TradeSpecialties"); + if (filter == "location") + sql.Should().Contain("City"); + if (filter == "job-bucket") + sql.Should().Contain("UNION"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs new file mode 100644 index 0000000..1067869 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -0,0 +1,213 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorServiceTests +{ + private static VendorService NewService(Mock data) => + new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation()); + + [Fact] + public async Task CreateVendor_PersistsCompleteProfile() + { + var data = new Mock(); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; }); + + var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO + { + Name = "Gateway Plumbing", + ContactName = "Jordan Lee", + Email = "dispatch@gateway.example", + Phone = "555-0100", + CompanyPhone = "555-0199", + Address = "10 Lake St", + City = "Chicago", + State = "IL", + Zipcode = "60601", + TradeSpecialties = "Plumbing, Backflow", + GoogleMapsUrl = "https://maps.google.com/example", + Notes = "After-hours dispatch available", + IsActive = true + }, "42"); + + saved.Should().NotBeNull(); + saved!.ContactName.Should().Be("Jordan Lee"); + saved.TradeSpecialties.Should().Be("Plumbing, Backflow"); + saved.CompanyPhone.Should().Be("555-0199"); + saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example"); + saved.Notes.Should().Be("After-hours dispatch available"); + saved.City.Should().Be("Chicago"); + result.IsActive.Should().BeTrue(); + result.ContactName.Should().Be("Jordan Lee"); + } + + [Fact] + public async Task UpdateVendor_UpdatesProfileAndActiveState() + { + var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO + { + Name = "New", + ContactName = "Casey", + TradeSpecialties = "HVAC", + IsActive = false + }, "42"); + + existing.CompanyName.Should().Be("New"); + existing.ContactName.Should().Be("Casey"); + existing.TradeSpecialties.Should().Be("HVAC"); + existing.IsActive.Should().BeFalse(); + existing.LastModifierUserId.Should().Be(42); + result.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task DeleteVendor_DeactivatesWithoutDeletingHistory() + { + var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).DeleteVendorAsync(9, "42"); + + existing.IsActive.Should().BeFalse(); + existing.IsDeleted.Should().NotBeTrue(); + existing.DeletionTime.Should().BeNull(); + existing.LastModificationTime.Should().NotBeNull(); + } + + [Fact] + public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields() + { + var data = new Mock(); + data.Setup(x => x.GetPagedAsync( + 1, + 12, + "chicago", + false, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>())) + .ReturnsAsync((new[] + { + new Vendor + { + Id = 10, + CompanyName = "Inactive Vendor", + ContactName = "Taylor", + City = "Chicago", + State = "IL", + TradeSpecialties = "Electrical", + IsActive = false + } + }.AsEnumerable(), 1)); + + var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false); + + result.TotalCount.Should().Be(1); + result.Items.Single().Should().BeEquivalentTo(new + { + Id = 10, + Name = "Inactive Vendor", + ContactName = "Taylor", + City = "Chicago", + State = "IL", + TradeSpecialties = "Electrical", + IsActive = false + }, options => options.ExcludingMissingMembers()); + } + + [Fact] + public async Task GetVendorById_CountsDistinctHistoricalWorkOrders() + { + var vendor = new Vendor + { + Id = 11, + CompanyName = "History Vendor", + Dispatches = new List + { + new() + { + WorkOrderId = 100, + DispatchWorkOrders = new List + { + new() { WorkOrderId = 100 }, + new() { WorkOrderId = 101 } + } + }, + new() { WorkOrderId = 101 } + } + }; + var data = new Mock(); + data.Setup(x => x.GetByIdWithDetailsAsync(11)).ReturnsAsync(vendor); + + var result = await NewService(data).GetVendorByIdAsync(11); + + result.Should().NotBeNull(); + result!.TotalJobs.Should().Be(2); + } + + [Fact] + public async Task GetVendorsPaged_ForwardsFacetFilters() + { + var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" }; + var trades = new[] { "Backflow Preventers" }; + var locations = new[] { "Dallas, TX" }; + var jobBuckets = new[] { "100-149" }; + var data = new Mock(); + data.Setup(x => x.GetPagedAsync( + 1, + 25, + "gateway", + true, + companies, + trades, + locations, + jobBuckets)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await NewService(data).GetVendorsPagedAsync( + 1, + 25, + "gateway", + true, + companies, + trades, + locations, + jobBuckets); + + data.VerifyAll(); + } + + [Fact] + public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl() + { + var validator = new CreateVendorValidation(); + + var result = validator.Validate(new CreateVendorDTO + { + Name = "Gateway Plumbing", + ContactName = "", + GoogleMapsUrl = "http://maps.google.com/gateway" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); + result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 9f6afc7..ecc4ccd 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -32,16 +32,43 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetVendorList")] - public async Task GetVendorList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetVendorList( + string? search = "", + int page = 1, + int pageSize = 10, + bool? isActive = true, + [FromQuery] string[]? companies = null, + [FromQuery] string[]? trades = null, + [FromQuery] string[]? locations = null, + [FromQuery] string[]? jobBuckets = null) { - var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search); + var pagedResult = await _vendorService.GetVendorsPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets); var data = pagedResult.Items.Select(v => new { v.Id, CompanyName = v.Name, + v.ContactName, v.Email, - v.Phone + v.Phone, + v.CompanyPhone, + v.Address, + v.City, + v.State, + Zip = v.Zipcode, + v.TradeSpecialties, + v.GoogleMapsUrl, + v.Notes, + v.IsActive, + v.TotalJobs }); return Ok(new Pagination_DTO @@ -56,17 +83,35 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("{id}")] [HttpGet("GetById")] - public async Task GetVendorById([FromQuery] int? id) + public async Task GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId) { - if (id == null) + var vendorId = id ?? queryId; + if (vendorId == null) return BadRequest(new Response { Status = "Error", Message = "Id is required" }); - var vendor = await _vendorService.GetVendorByIdAsync(id.Value); + var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value); if (vendor == null) return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); - return Ok(vendor); + return Ok(new + { + vendor.Id, + CompanyName = vendor.Name, + vendor.ContactName, + vendor.Email, + vendor.Phone, + vendor.CompanyPhone, + vendor.Address, + vendor.City, + vendor.State, + Zip = vendor.Zipcode, + vendor.TradeSpecialties, + vendor.GoogleMapsUrl, + vendor.Notes, + vendor.IsActive, + vendor.TotalJobs + }); } [HttpPost] @@ -77,7 +122,19 @@ namespace Api.SeaHavenIndustries.Controllers { var createDto = new CreateVendorDTO { - Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required") + Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"), + ContactName = model.ContactName, + Email = model.Email, + Phone = model.Phone, + CompanyPhone = model.CompanyPhone, + Address = model.Address, + City = model.City, + State = model.State, + Zipcode = model.Zip, + TradeSpecialties = model.TradeSpecialties, + GoogleMapsUrl = model.GoogleMapsUrl, + Notes = model.Notes, + IsActive = model.IsActive ?? true }; var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -111,7 +168,19 @@ namespace Api.SeaHavenIndustries.Controllers var updateDto = new UpdateVendorDTO { - Name = model.CompanyName + Name = model.CompanyName, + ContactName = model.ContactName, + Email = model.Email, + Phone = model.Phone, + CompanyPhone = model.CompanyPhone, + Address = model.Address, + City = model.City, + State = model.State, + Zipcode = model.Zip, + TradeSpecialties = model.TradeSpecialties, + GoogleMapsUrl = model.GoogleMapsUrl, + Notes = model.Notes, + IsActive = model.IsActive }; var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -126,7 +195,7 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } - catch (KeyNotFoundException) + catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); } @@ -138,7 +207,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpDelete("{id}")] [HttpPost("Delete")] - public async Task Delete([FromRoute] int? id, [FromQuery] int? queryId = null) + public async Task Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null) { try { @@ -152,9 +221,9 @@ namespace Api.SeaHavenIndustries.Controllers return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); await _vendorService.DeleteVendorAsync(vendorId, userId); - return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" }); + return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" }); } - catch (KeyNotFoundException) + catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); } @@ -212,6 +281,64 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(result); } + [HttpGet("facets")] + public async Task GetFacets([FromQuery] bool? isActive = null) + { + var query = _db.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); + + var vendors = await query + .OrderByDescending(v => v.Id) + .ToListAsync(); + + var companies = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) + .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(v => v.CompanyName) + .Select(v => new + { + Name = v.CompanyName, + v.CompanyPhone, + v.Email, + v.Address, + v.City, + v.State, + Zip = v.Zip, + v.GoogleMapsUrl + }); + + var trades = vendors + .SelectMany(v => (v.TradeSpecialties ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value); + + var locations = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) + .Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) + .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) + .OrderBy(location => location.Label); + + return Ok(new + { + Companies = companies, + Trades = trades, + Locations = locations, + JobBuckets = new[] + { + new { Id = "under-50", Label = "Under 50" }, + new { Id = "50-99", Label = "50–99" }, + new { Id = "100-149", Label = "100–149" }, + new { Id = "150-plus", Label = "150+" } + } + }); + } + [HttpGet("{id:int}/portal-token")] public async Task GetPortalToken(int id) { diff --git a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs index f283379..d2a4717 100644 --- a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs @@ -6,12 +6,15 @@ namespace Api.SeaHavenIndustries.DTOs public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zip { get; set; } public string? TradeSpecialties { get; set; } - public bool IsActive { get; set; } = true; + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool? IsActive { get; set; } } public class EditVendor_DTO : Vendor_DTO diff --git a/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs b/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs new file mode 100644 index 0000000..ad4b3b1 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs @@ -0,0 +1,41 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260720220000_AddVendorDirectoryDetails")] + public partial class AddVendorDirectoryDetails : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CompanyPhone", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "GoogleMapsUrl", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Notes", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "CompanyPhone", table: "Vendors"); + migrationBuilder.DropColumn(name: "GoogleMapsUrl", table: "Vendors"); + migrationBuilder.DropColumn(name: "Notes", table: "Vendors"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 43e63e3..190a690 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1733,6 +1733,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("CompanyName") .HasColumnType("nvarchar(max)"); + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + b.Property("ContactName") .HasColumnType("nvarchar(max)"); @@ -1748,6 +1751,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Email") .HasColumnType("nvarchar(max)"); + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + b.Property("IsActive") .HasColumnType("bit"); @@ -1760,6 +1766,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("LastModifierUserId") .HasColumnType("int"); + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + b.Property("Phone") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index d6f623a..d1d48b7 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations.Schema; + namespace Data.SeaHavenIndustries { public class Vendor : FullAuditEntity @@ -6,12 +8,17 @@ namespace Data.SeaHavenIndustries public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zip { get; set; } public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } public bool IsActive { get; set; } = true; + [NotMapped] + public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 6eb2eeb..273dec0 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using System.Reflection; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -15,13 +17,16 @@ namespace SeaHaven.DataServices.Implementation public async Task GetByIdAsync(int id) { - return await _context.Vendors.FindAsync(id); + return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id); } public async Task GetByIdWithDetailsAsync(int id) { - return await _context.Vendors - .FirstOrDefaultAsync(v => v.Id == id); + return await ProjectWithTotalJobs( + _context.Vendors + .AsNoTracking() + .Where(v => v.Id == id)) + .FirstOrDefaultAsync(); } public async Task> GetAllAsync() @@ -32,24 +37,23 @@ namespace SeaHaven.DataServices.Implementation public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, - string? search = null) + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) { - search ??= ""; + var projectedQuery = BuildDirectoryQuery( + search, + isActive, + companies, + trades, + locations, + jobBuckets); - var query = _context.Vendors.Where(v => v.IsActive); - - if (!string.IsNullOrWhiteSpace(search)) - { - var s = search.ToLower(); - query = query.Where(v => - (v.CompanyName ?? "").ToLower().Contains(s) || - (v.ContactName ?? "").ToLower().Contains(s) || - (v.Email ?? "").ToLower().Contains(s) || - (v.TradeSpecialties ?? "").ToLower().Contains(s)); - } - - var totalCount = await query.CountAsync(); - var items = await query + var totalCount = await projectedQuery.CountAsync(); + var items = await projectedQuery .OrderByDescending(v => v.Id) .Skip((page - 1) * pageSize) .Take(pageSize) @@ -58,6 +62,198 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + internal IQueryable BuildDirectoryQuery( + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) + { + var query = _context.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); + + if (!string.IsNullOrWhiteSpace(search)) + { + var normalizedSearch = search.ToLower(); + query = query.Where(v => + (v.CompanyName ?? "").ToLower().Contains(normalizedSearch) || + (v.ContactName ?? "").ToLower().Contains(normalizedSearch) || + (v.Email ?? "").ToLower().Contains(normalizedSearch) || + (v.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) || + (v.City ?? "").ToLower().Contains(normalizedSearch) || + (v.State ?? "").ToLower().Contains(normalizedSearch) || + (v.Zip ?? "").ToLower().Contains(normalizedSearch)); + } + + var selectedCompanies = companies? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedCompanies.Length > 0) + query = query.Where(v => + selectedCompanies.Contains((v.CompanyName ?? "").ToLower())); + + var selectedTrades = trades? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedTrades.Length > 0) + query = query.Where(BuildTradePredicate(selectedTrades)); + + var selectedLocations = locations? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedLocations.Length > 0) + query = query.Where(BuildLocationPredicate(selectedLocations)); + + var result = ProjectWithTotalJobs(query); + var selectedJobBuckets = jobBuckets? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedJobBuckets.Length == 0) + return result; + + var under50 = selectedJobBuckets.Contains("under-50"); + var from50To99 = selectedJobBuckets.Contains("50-99"); + var from100To149 = selectedJobBuckets.Contains("100-149"); + var from150 = selectedJobBuckets.Contains("150-plus"); + + return result.Where(v => + (under50 && v.TotalJobs < 50) || + (from50To99 && v.TotalJobs >= 50 && v.TotalJobs <= 99) || + (from100To149 && v.TotalJobs >= 100 && v.TotalJobs <= 149) || + (from150 && v.TotalJobs >= 150)); + } + + private IQueryable ProjectWithTotalJobs(IQueryable query) => + query.Select(v => new Vendor + { + Id = v.Id, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + Email = v.Email, + Phone = v.Phone, + CompanyPhone = v.CompanyPhone, + Address = v.Address, + City = v.City, + State = v.State, + Zip = v.Zip, + TradeSpecialties = v.TradeSpecialties, + GoogleMapsUrl = v.GoogleMapsUrl, + Notes = v.Notes, + IsActive = v.IsActive, + IsDeleted = v.IsDeleted, + TotalJobs = _context.Dispatches + .Where(dispatch => + dispatch.VendorId == v.Id && + dispatch.WorkOrderId.HasValue) + .Select(dispatch => dispatch.WorkOrderId!.Value) + .Union( + _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id) + .Select(link => link.WorkOrderId)) + .Count() + }); + + private static readonly MethodInfo StringConcatMethod = + typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!; + + private static Expression> BuildTradePredicate( + IReadOnlyCollection selectedTrades) + { + var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var specialties = Expression.Coalesce( + Expression.Property(vendor, nameof(Vendor.TradeSpecialties)), + Expression.Constant(string.Empty)); + var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes); + + var delimiters = new[] { ", ", "," }; + Expression? body = null; + + foreach (var trade in selectedTrades) + { + var token = Expression.Constant(trade); + + body = OrElse(body, Expression.Equal(lowered, token)); + + foreach (var delimiter in delimiters) + { + var delim = Expression.Constant(delimiter); + var tokenWithTrailing = Concat(token, delim); + var leadingToken = Concat(delim, token); + var wrappedToken = Concat(leadingToken, delim); + + body = OrElse(body, StartsWith(lowered, tokenWithTrailing)); + body = OrElse(body, EndsWith(lowered, leadingToken)); + body = OrElse(body, Contains(lowered, wrappedToken)); + } + } + + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + } + + private static Expression> BuildLocationPredicate( + IReadOnlyCollection selectedLocations) + { + var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var label = BuildLocationLabelBody(vendor); + var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes); + + Expression? body = null; + foreach (var location in selectedLocations) + body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location))); + + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + } + + private static Expression BuildLocationLabelBody(ParameterExpression vendor) + { + var city = Expression.Property(vendor, nameof(Vendor.City)); + var state = Expression.Property(vendor, nameof(Vendor.State)); + var cityBlank = IsBlank(city); + var stateBlank = IsBlank(state); + + var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state); + + var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state); + var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState); + return Expression.Condition(cityBlank, onlyState, cityOrBoth); + } + + private static Expression IsBlank(Expression value) + { + var isNull = Expression.Equal(value, Expression.Constant(null, value.Type)); + var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes); + var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty)); + return Expression.Condition(isNull, Expression.Constant(true), isEmpty); + } + + private static Expression Concat(Expression left, Expression right) => + Expression.Call(StringConcatMethod, left, right); + + private static Expression StartsWith(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value); + + private static Expression EndsWith(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value); + + private static Expression Contains(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value); + + private static Expression OrElse(Expression? accumulated, Expression next) => + accumulated is null ? next : Expression.OrElse(accumulated, next); + public async Task AddAsync(Vendor vendor) { await _context.Vendors.AddAsync(vendor); diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 6458b4f..2dbd0c1 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -7,7 +7,15 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdAsync(int id); Task GetByIdWithDetailsAsync(int id); Task> GetAllAsync(); - Task<(IEnumerable Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null); + Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null); Task AddAsync(Vendor vendor); Task UpdateAsync(Vendor vendor); Task DeleteAsync(int id); diff --git a/SeaHaven.DataServices/Properties/AssemblyInfo.cs b/SeaHaven.DataServices/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..75fc406 --- /dev/null +++ b/SeaHaven.DataServices/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")] diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 3a58934..6c1cd82 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -4,33 +4,52 @@ namespace SeaHaven.Services.DTOs { public int Id { get; set; } public string? Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public int TotalJobs { get; set; } + public bool IsActive { get; set; } } public class CreateVendorDTO { public required string Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } = true; } public class UpdateVendorDTO { public string? Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool? IsActive { get; set; } } } diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index eccc405..6ba8e71 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -29,7 +29,7 @@ namespace SeaHaven.Services.Implementation public async Task GetVendorByIdAsync(int id) { - var vendor = await _vendorDataService.GetByIdAsync(id); + var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id); return vendor == null ? null : MapToDTO(vendor); } @@ -45,9 +45,25 @@ namespace SeaHaven.Services.Implementation return vendors.Select(MapToDTO); } - public async Task> GetVendorsPagedAsync(int page, int pageSize, string? search = null) + public async Task> GetVendorsPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) { - var (items, totalCount) = await _vendorDataService.GetPagedAsync(page, pageSize, search); + var (items, totalCount) = await _vendorDataService.GetPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets); return new PagedResult { Items = items.Select(MapToDTO).ToList(), @@ -68,12 +84,18 @@ namespace SeaHaven.Services.Implementation var vendor = new Vendor { CompanyName = dto.Name, + ContactName = dto.ContactName, Email = dto.Email, Phone = dto.Phone, + CompanyPhone = dto.CompanyPhone, Address = dto.Address, City = dto.City, State = dto.State, Zip = dto.Zipcode, + TradeSpecialties = dto.TradeSpecialties, + GoogleMapsUrl = dto.GoogleMapsUrl, + Notes = dto.Notes, + IsActive = dto.IsActive, CreatedDate = DateTime.UtcNow, createdby = userId }; @@ -95,12 +117,18 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException($"Vendor with ID {id} not found"); if (dto.Name != null) vendor.CompanyName = dto.Name; + if (dto.ContactName != null) vendor.ContactName = dto.ContactName; if (dto.Email != null) vendor.Email = dto.Email; if (dto.Phone != null) vendor.Phone = dto.Phone; + if (dto.CompanyPhone != null) vendor.CompanyPhone = dto.CompanyPhone; if (dto.Address != null) vendor.Address = dto.Address; if (dto.City != null) vendor.City = dto.City; if (dto.State != null) vendor.State = dto.State; if (dto.Zipcode != null) vendor.Zip = dto.Zipcode; + if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties; + if (dto.GoogleMapsUrl != null) vendor.GoogleMapsUrl = dto.GoogleMapsUrl; + if (dto.Notes != null) vendor.Notes = dto.Notes; + if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value; vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) @@ -116,9 +144,10 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); - vendor.IsDeleted = true; - vendor.DeleterUserId = userId; - vendor.DeletionTime = DateTime.UtcNow; + vendor.IsActive = false; + vendor.LastModificationTime = DateTime.UtcNow; + if (int.TryParse(userId, out int userIdInt)) + vendor.LastModifierUserId = userIdInt; await _vendorDataService.UpdateAsync(vendor); } @@ -139,13 +168,40 @@ namespace SeaHaven.Services.Implementation { Id = vendor.Id, Name = vendor.CompanyName, + ContactName = vendor.ContactName, Email = vendor.Email, Phone = vendor.Phone, + CompanyPhone = vendor.CompanyPhone, Address = vendor.Address, City = vendor.City, State = vendor.State, - Zipcode = vendor.Zip + Zipcode = vendor.Zip, + TradeSpecialties = vendor.TradeSpecialties, + GoogleMapsUrl = vendor.GoogleMapsUrl, + Notes = vendor.Notes, + IsActive = vendor.IsActive, + TotalJobs = vendor.Dispatches == null + ? vendor.TotalJobs + : CountDistinctWorkOrders(vendor) }; } + + private static int CountDistinctWorkOrders(Vendor vendor) + { + if (vendor.Dispatches == null) + return 0; + + return vendor.Dispatches + .SelectMany(dispatch => + { + var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId) + ?? Enumerable.Empty(); + return dispatch.WorkOrderId.HasValue + ? linked.Append(dispatch.WorkOrderId.Value) + : linked; + }) + .Distinct() + .Count(); + } } } diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 13d996b..aad7b13 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -7,7 +7,15 @@ namespace SeaHaven.Services.Interfaces Task GetVendorByIdAsync(int id); Task GetVendorByIdWithDetailsAsync(int id); Task> GetAllVendorsAsync(); - Task> GetVendorsPagedAsync(int page, int pageSize, string? search = null); + Task> GetVendorsPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null); Task CreateVendorAsync(CreateVendorDTO dto, string userId); Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId); Task DeleteVendorAsync(int id, string userId); diff --git a/SeaHaven.Services/Validation/VendorValidation.cs b/SeaHaven.Services/Validation/VendorValidation.cs index b2f5c03..acce074 100644 --- a/SeaHaven.Services/Validation/VendorValidation.cs +++ b/SeaHaven.Services/Validation/VendorValidation.cs @@ -19,10 +19,22 @@ namespace SeaHaven.Services.Validation .EmailAddress().WithMessage("Invalid email address") .When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.ContactName) + .NotEmpty().WithMessage("Technician name is required") + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters"); + + RuleFor(x => x.TradeSpecialties) + .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") + .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); + RuleFor(x => x.Phone) .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") .When(x => !string.IsNullOrEmpty(x.Phone)); + RuleFor(x => x.CompanyPhone) + .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); + RuleFor(x => x.Address) .MaximumLength(500).WithMessage("Address cannot exceed 500 characters") .When(x => !string.IsNullOrEmpty(x.Address)); @@ -38,6 +50,14 @@ namespace SeaHaven.Services.Validation RuleFor(x => x.Zipcode) .MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters") .When(x => !string.IsNullOrEmpty(x.Zipcode)); + + RuleFor(x => x.GoogleMapsUrl) + .Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL") + .When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); } } @@ -57,10 +77,23 @@ namespace SeaHaven.Services.Validation .EmailAddress().WithMessage("Invalid email address") .When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.ContactName) + .NotEmpty().WithMessage("Technician name is required") + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") + .When(x => x.ContactName != null); + + RuleFor(x => x.TradeSpecialties) + .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") + .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); + RuleFor(x => x.Phone) .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") .When(x => !string.IsNullOrEmpty(x.Phone)); + RuleFor(x => x.CompanyPhone) + .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); + RuleFor(x => x.Address) .MaximumLength(500).WithMessage("Address cannot exceed 500 characters") .When(x => !string.IsNullOrEmpty(x.Address)); @@ -76,6 +109,21 @@ namespace SeaHaven.Services.Validation RuleFor(x => x.Zipcode) .MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters") .When(x => !string.IsNullOrEmpty(x.Zipcode)); + + RuleFor(x => x.GoogleMapsUrl) + .Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL") + .When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); } + + } + + internal static class VendorValidationRules + { + internal static bool BeValidHttpsUrl(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; } } From 8cf49afc2c395208440b88a8905855c60df8b300 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 17:02:40 -0300 Subject: [PATCH 14/30] feat(vendors): complete core vendor workflows --- .../VendorDataServiceTests.cs | 55 ++++ .../VendorServiceTests.cs | 183 +++++++++++- .../Controllers/VendorController.cs | 90 ++++++ Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs | 12 + .../Auth/ApplicationDbContext.cs | 12 + ...20260723194013_AddVendorCompanyAndAudit.cs | 185 ++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 144 +++++++++- .../Models/LinkedWorkOrderInfo.cs | 15 + Data.SeaHavenIndustries/Models/Vendor.cs | 7 + .../Models/VendorAuditLog.cs | 18 ++ .../Models/VendorCompany.cs | 19 ++ .../Implementation/VendorDataService.cs | 80 ++++++ .../Interfaces/IVendorDataService.cs | 7 + SeaHaven.Services/DTOs/VendorDTOs.cs | 44 +++ .../Helpers/VendorPhoneNormalizer.cs | 25 ++ .../Implementation/VendorService.cs | 269 +++++++++++++++++- .../Interfaces/IVendorService.cs | 2 + .../Validation/VendorValidation.cs | 66 ++++- 18 files changed, 1213 insertions(+), 20 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs create mode 100644 Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs create mode 100644 Data.SeaHavenIndustries/Models/VendorAuditLog.cs create mode 100644 Data.SeaHavenIndustries/Models/VendorCompany.cs create mode 100644 SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 31c4940..80057f5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; @@ -266,4 +267,58 @@ public class VendorDataServiceTests if (filter == "job-bucket") sql.Should().Contain("UNION"); } + + [Fact] + public void BuildOpenLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation() + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var sql = service.BuildOpenLinkedWorkOrdersQuery(60).ToQueryString(); + + sql.Should().Contain("SELECT"); + sql.Should().Contain("Dispatches"); + sql.Should().Contain("DispatchWorkOrders"); + sql.Should().Contain("workOrders"); + } + + [Fact] + public async Task GetOpenLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks() + { + await using var context = NewContext(); + var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true }; + context.Vendors.Add(vendor); + context.workOrders.AddRange( + new WorkOrder + { + Id = 701, + WorkerOrderNumber = "WO-701", + WorkerOrderTitle = "Direct And Multi", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 702, + WorkerOrderNumber = "WO-702", + WorkerOrderTitle = "Multi Only", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + }); + + var directDispatch = new Dispatch { Id = 7001, VendorId = 60, WorkOrderId = 701, Vendor = vendor }; + var multiDispatch = new Dispatch { Id = 7002, VendorId = 60, WorkOrderId = null, Vendor = vendor }; + context.Dispatches.AddRange(directDispatch, multiDispatch); + context.DispatchWorkOrders.AddRange( + new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 702 }, + new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 701 }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var result = await service.GetOpenLinkedWorkOrdersAsync(60); + + result.Should().HaveCount(2); + result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 }); + result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems(); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 1067869..9c49893 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentAssertions; +using FluentValidation; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; @@ -12,7 +14,7 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorServiceTests { private static VendorService NewService(Mock data) => - new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation()); + new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); [Fact] public async Task CreateVendor_PersistsCompleteProfile() @@ -27,8 +29,8 @@ public class VendorServiceTests Name = "Gateway Plumbing", ContactName = "Jordan Lee", Email = "dispatch@gateway.example", - Phone = "555-0100", - CompanyPhone = "555-0199", + Phone = "(555) 555-0100", + CompanyPhone = "(555) 555-0199", Address = "10 Lake St", City = "Chicago", State = "IL", @@ -42,7 +44,7 @@ public class VendorServiceTests saved.Should().NotBeNull(); saved!.ContactName.Should().Be("Jordan Lee"); saved.TradeSpecialties.Should().Be("Plumbing, Backflow"); - saved.CompanyPhone.Should().Be("555-0199"); + saved.CompanyPhone.Should().Be("(555) 555-0199"); saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example"); saved.Notes.Should().Be("After-hours dispatch available"); saved.City.Should().Be("Chicago"); @@ -56,6 +58,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); + data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO @@ -80,6 +83,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); + data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); await NewService(data).DeleteVendorAsync(9, "42"); @@ -210,4 +214,175 @@ public class VendorServiceTests result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); } + + [Fact] + public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive() + { + var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing); + data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(20)) + .ReturnsAsync(new List + { + new() + { + WorkOrderId = 500, + WorkOrderNumber = "WO-500", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + } + }); + + var act = () => NewService(data).UpdateVendorAsync(20, new UpdateVendorDTO { IsActive = false }, "42"); + + await act.Should().ThrowAsync(); + existing.IsActive.Should().BeTrue(); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName() + { + var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(30)).ReturnsAsync(existing); + data.Setup(x => x.GetCompanyByIdAsync(999)).ReturnsAsync((VendorCompany?)null); + + var act = () => NewService(data).UpdateVendorAsync(30, new UpdateVendorDTO + { + CompanyId = 999, + Name = "Fallback Try" + }, "42"); + + await act.Should().ThrowAsync(); + data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny()), Times.Never); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany() + { + var data = new Mock(); + var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" }; + data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; }); + + var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO + { + Name = "Acme", + CompanyId = 77, + ContactName = "Riley" + }, "42"); + + saved.Should().NotBeNull(); + saved!.CompanyId.Should().Be(77); + result.CompanyId.Should().Be(77); + data.Verify(x => x.AddCompanyAsync(It.IsAny()), Times.Never); + } + + [Fact] + public void CreateVendor_RejectsNonCanonicalPhoneAndInvalidPreferredContact() + { + var validator = new CreateVendorValidation(); + + var result = validator.Validate(new CreateVendorDTO + { + Name = "Acme", + ContactName = "Riley", + Phone = "555-1234", + PreferredContact = "Fax" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.Phone)); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact)); + } + + [Fact] + public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact() + { + var validator = new WorkOrderVendorUpdateValidation(); + + var result = validator.Validate(new WorkOrderVendorUpdateDTO + { + WorkOrderId = 5, + Phone = "(312) 555-0100", + PreferredContact = "Text" + }); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public async Task UpdateVendorFromWorkOrder_RejectsUnassignedWorkOrder() + { + var existing = new Vendor { Id = 50, ContactName = "Original", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing); + data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(false); + + var act = () => NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO + { + WorkOrderId = 777, + ContactName = "Should Not Apply" + }, "42"); + + await act.Should().ThrowAsync(); + existing.ContactName.Should().Be("Original"); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateVendorFromWorkOrder_RecordsAuditOldAndNewValues() + { + var existing = new Vendor + { + Id = 50, + CompanyName = "Assigned", + ContactName = "Old Name", + Phone = "(312) 555-0000", + PreferredContact = "Phone", + IsActive = true + }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing); + data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true); + var auditLogs = new List(); + data.Setup(x => x.UpdateWithAuditLogsAsync( + existing, + It.IsAny>())) + .Callback>((_, logs) => auditLogs.AddRange(logs)) + .Returns(Task.CompletedTask); + + await NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO + { + WorkOrderId = 777, + ContactName = "New Name", + Phone = "(312) 555-0199", + PreferredContact = "Email" + }, "42"); + + existing.ContactName.Should().Be("New Name"); + existing.Phone.Should().Be("(312) 555-0199"); + existing.PreferredContact.Should().Be("Email"); + auditLogs.Should().NotBeEmpty(); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.ContactName) && a.OldValue == "Old Name" && a.NewValue == "New Name"); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.Phone) && a.OldValue == "(312) 555-0000" && a.NewValue == "(312) 555-0199"); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.PreferredContact) && a.OldValue == "Phone" && a.NewValue == "Email"); + } + + [Fact] + public async Task GetDeactivationImpact_RejectsUnknownVendor() + { + var data = new Mock(); + data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false); + + var act = () => NewService(data).GetDeactivationImpactAsync(404); + + await act.Should().ThrowAsync() + .WithMessage("*404*not found"); + data.Verify(x => x.GetOpenLinkedWorkOrdersAsync(It.IsAny()), Times.Never); + } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index ecc4ccd..00763f0 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -56,10 +56,12 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, CompanyName = v.Name, + v.CompanyId, v.ContactName, v.Email, v.Phone, v.CompanyPhone, + v.PreferredContact, v.Address, v.City, v.State, @@ -98,10 +100,12 @@ namespace Api.SeaHavenIndustries.Controllers { vendor.Id, CompanyName = vendor.Name, + vendor.CompanyId, vendor.ContactName, vendor.Email, vendor.Phone, vendor.CompanyPhone, + vendor.PreferredContact, vendor.Address, vendor.City, vendor.State, @@ -123,10 +127,12 @@ namespace Api.SeaHavenIndustries.Controllers var createDto = new CreateVendorDTO { Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"), + CompanyId = model.CompanyId, ContactName = model.ContactName, Email = model.Email, Phone = model.Phone, CompanyPhone = model.CompanyPhone, + PreferredContact = model.PreferredContact, Address = model.Address, City = model.City, State = model.State, @@ -169,10 +175,12 @@ namespace Api.SeaHavenIndustries.Controllers var updateDto = new UpdateVendorDTO { Name = model.CompanyName, + CompanyId = model.CompanyId, ContactName = model.ContactName, Email = model.Email, Phone = model.Phone, CompanyPhone = model.CompanyPhone, + PreferredContact = model.PreferredContact, Address = model.Address, City = model.City, State = model.State, @@ -195,6 +203,15 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (VendorDeactivationBlockedException dbex) + { + return Conflict(new + { + Status = "Conflict", + Message = dbex.Message, + OpenWorkOrders = dbex.OpenWorkOrders + }); + } catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); @@ -223,6 +240,15 @@ namespace Api.SeaHavenIndustries.Controllers await _vendorService.DeleteVendorAsync(vendorId, userId); return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" }); } + catch (VendorDeactivationBlockedException dbex) + { + return Conflict(new + { + Status = "Conflict", + Message = dbex.Message, + OpenWorkOrders = dbex.OpenWorkOrders + }); + } catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); @@ -233,6 +259,64 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("{id:int}/deactivation-impact")] + public async Task GetDeactivationImpact(int id) + { + try + { + var impact = await _vendorService.GetDeactivationImpactAsync(id); + return Ok(impact); + } + catch (InvalidOperationException) + { + return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpPut("{id:int}/work-order-update")] + public async Task UpdateFromWorkOrder(int id, [FromBody] WorkOrderVendorUpdate_DTO model) + { + try + { + if (model == null || model.WorkOrderId <= 0) + return BadRequest(new Response { Status = "Error", Message = "A valid workOrderId is required" }); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + var dto = new WorkOrderVendorUpdateDTO + { + WorkOrderId = model.WorkOrderId, + ContactName = model.ContactName, + PreferredContact = model.PreferredContact, + Phone = model.Phone, + Email = model.Email, + Notes = model.Notes + }; + + var result = await _vendorService.UpdateVendorFromWorkOrderAsync(id, dto, userId); + return Ok(result); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (InvalidOperationException) + { + return NotFound(new Response { Status = "Error", Message = "Vendor or work order link not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + [HttpGet("Dropdown")] public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null) { @@ -243,6 +327,8 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, + v.PreferredContact, v.TradeSpecialties, v.Address, v.City, @@ -260,6 +346,8 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, + v.PreferredContact, v.TradeSpecialties, address = string.Join(", ", addr), distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null @@ -303,8 +391,10 @@ namespace Api.SeaHavenIndustries.Controllers .Select(v => new { Name = v.CompanyName, + v.CompanyId, v.CompanyPhone, v.Email, + v.PreferredContact, v.Address, v.City, v.State, diff --git a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs index d2a4717..6249bb8 100644 --- a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs @@ -3,10 +3,12 @@ namespace Api.SeaHavenIndustries.DTOs public class Vendor_DTO { public string? CompanyName { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -21,4 +23,14 @@ namespace Api.SeaHavenIndustries.DTOs { public int Id { get; set; } } + + public class WorkOrderVendorUpdate_DTO + { + public int WorkOrderId { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Notes { get; set; } + } } diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a34d5d4..c7c85c6 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -51,6 +51,16 @@ namespace Data.SeaHavenIndustries .WithMany() .HasForeignKey(w => w.PrimaryDispatchId) .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(c => c.NormalizedName) + .IsUnique(); + + builder.Entity() + .HasOne(v => v.Company) + .WithMany(c => c.Vendors) + .HasForeignKey(v => v.CompanyId) + .OnDelete(DeleteBehavior.Restrict); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -68,6 +78,8 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderFieldLocks { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } + public DbSet VendorCompanies { get; set; } + public DbSet VendorAuditLogs { get; set; } public DbSet VendorAccessTokens { get; set; } public DbSet Dispatches { get; set; } public DbSet DispatchWorkOrders { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs b/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs new file mode 100644 index 0000000..bda0f92 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs @@ -0,0 +1,185 @@ +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260723194013_AddVendorCompanyAndAudit")] + public partial class AddVendorCompanyAndAudit : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CompanyId", + table: "Vendors", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "PreferredContact", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.CreateTable( + name: "VendorCompanies", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + NormalizedName = table.Column(type: "nvarchar(450)", nullable: false), + CompanyPhone = table.Column(type: "nvarchar(max)", nullable: true), + Email = table.Column(type: "nvarchar(max)", nullable: true), + Address = table.Column(type: "nvarchar(max)", nullable: true), + City = table.Column(type: "nvarchar(max)", nullable: true), + State = table.Column(type: "nvarchar(max)", nullable: true), + Zip = table.Column(type: "nvarchar(max)", nullable: true), + GoogleMapsUrl = table.Column(type: "nvarchar(max)", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorCompanies", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VendorAuditLogs", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + VendorId = table.Column(type: "int", nullable: false), + WorkOrderId = table.Column(type: "int", nullable: true), + FieldName = table.Column(type: "nvarchar(max)", nullable: true), + OldValue = table.Column(type: "nvarchar(max)", nullable: true), + NewValue = table.Column(type: "nvarchar(max)", nullable: true), + Actor = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorAuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_VendorAuditLogs_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + // Backfill: create one VendorCompany per distinct (case-insensitive, trimmed) legacy name. + // Keep all company-owned fields from the same deterministic source vendor row. + migrationBuilder.Sql(@" +WITH RankedVendors AS ( + SELECT + v.[CompanyName] AS [Name], + LOWER(LTRIM(RTRIM(v.[CompanyName]))) AS [NormalizedName], + v.[CompanyPhone], + v.[Email], + v.[Address], + v.[City], + v.[State], + v.[Zip], + v.[GoogleMapsUrl], + ROW_NUMBER() OVER ( + PARTITION BY LOWER(LTRIM(RTRIM(v.[CompanyName]))) + ORDER BY v.[Id] + ) AS [RowNumber] + FROM [Vendors] v + WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> '' +) +INSERT INTO [VendorCompanies] ( + [Name], [NormalizedName], [CompanyPhone], [Email], [Address], [City], [State], [Zip], [GoogleMapsUrl], [CreatedDate] +) +SELECT + ranked.[Name], + ranked.[NormalizedName], + ranked.[CompanyPhone], + ranked.[Email], + ranked.[Address], + ranked.[City], + ranked.[State], + ranked.[Zip], + ranked.[GoogleMapsUrl], + GETUTCDATE() AS [CreatedDate] +FROM RankedVendors ranked +WHERE ranked.[RowNumber] = 1; + +UPDATE v +SET v.[CompanyId] = vc.[Id] +FROM [Vendors] v +INNER JOIN [VendorCompanies] vc + ON vc.[NormalizedName] = LOWER(LTRIM(RTRIM(v.[CompanyName]))) +WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> ''; +"); + + migrationBuilder.CreateIndex( + name: "IX_Vendors_CompanyId", + table: "Vendors", + column: "CompanyId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorAuditLogs_VendorId", + table: "VendorAuditLogs", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompanies_NormalizedName", + table: "VendorCompanies", + column: "NormalizedName", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Vendors_VendorCompanies_CompanyId", + table: "Vendors", + column: "CompanyId", + principalTable: "VendorCompanies", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Vendors_VendorCompanies_CompanyId", + table: "Vendors"); + + migrationBuilder.DropIndex( + name: "IX_Vendors_CompanyId", + table: "Vendors"); + + migrationBuilder.DropIndex( + name: "IX_VendorAuditLogs_VendorId", + table: "VendorAuditLogs"); + + migrationBuilder.DropIndex( + name: "IX_VendorCompanies_NormalizedName", + table: "VendorCompanies"); + + migrationBuilder.DropTable( + name: "VendorAuditLogs"); + + migrationBuilder.DropTable( + name: "VendorCompanies"); + + migrationBuilder.DropColumn( + name: "CompanyId", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "PreferredContact", + table: "Vendors"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 190a690..09b4ef3 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1730,6 +1730,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("City") .HasColumnType("nvarchar(max)"); + b.Property("CompanyId") + .HasColumnType("int"); + b.Property("CompanyName") .HasColumnType("nvarchar(max)"); @@ -1772,6 +1775,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Phone") .HasColumnType("nvarchar(max)"); + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + b.Property("State") .HasColumnType("nvarchar(max)"); @@ -1786,6 +1792,8 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); + b.HasIndex("CompanyId"); + b.ToTable("Vendors"); }); @@ -1848,6 +1856,108 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("VendorAccessTokens"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.Property("Id") @@ -1978,13 +2088,13 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("ScheduledStart") .HasColumnType("datetime2"); - b.Property("Severity") - .HasColumnType("nvarchar(max)"); - b.Property("Service") .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + b.Property("SignOffAttachment") .HasColumnType("nvarchar(max)"); @@ -2728,6 +2838,16 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("POC"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => { b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") @@ -2739,6 +2859,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Vendor"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") @@ -2972,9 +3103,16 @@ namespace Data.SeaHavenIndustries.Migrations modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => { + b.Navigation("AuditLogs"); + b.Navigation("Dispatches"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.Navigation("Comments"); diff --git a/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs b/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs new file mode 100644 index 0000000..db76d4b --- /dev/null +++ b/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs @@ -0,0 +1,15 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + public class LinkedWorkOrderInfo + { + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public string? WorkOrderTitle { get; set; } + public string? Status { get; set; } + public LifecycleStatus? LifecycleStatus { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? DispatchId { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index d1d48b7..9f8071a 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -17,8 +17,15 @@ namespace Data.SeaHavenIndustries public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } public bool IsActive { get; set; } = true; + public string? PreferredContact { get; set; } + + public int? CompanyId { get; set; } + [ForeignKey(nameof(CompanyId))] + public virtual VendorCompany? Company { get; set; } + [NotMapped] public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } + public ICollection? AuditLogs { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/VendorAuditLog.cs b/Data.SeaHavenIndustries/Models/VendorAuditLog.cs new file mode 100644 index 0000000..278b165 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorAuditLog.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorAuditLog + { + public int Id { get; set; } + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public virtual Vendor? Vendor { get; set; } + public int? WorkOrderId { get; set; } + public string? FieldName { get; set; } + public string? OldValue { get; set; } + public string? NewValue { get; set; } + public string? Actor { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/VendorCompany.cs b/Data.SeaHavenIndustries/Models/VendorCompany.cs new file mode 100644 index 0000000..5b2e062 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorCompany.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorCompany : FullAuditEntity + { + public string Name { get; set; } = string.Empty; + public string NormalizedName { get; set; } = string.Empty; + public string? CompanyPhone { get; set; } + public string? Email { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? GoogleMapsUrl { get; set; } + + public ICollection? Vendors { get; set; } + } +} diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 273dec0..e89620b 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -141,10 +141,12 @@ namespace SeaHaven.DataServices.Implementation { Id = v.Id, CompanyName = v.CompanyName, + CompanyId = v.CompanyId, ContactName = v.ContactName, Email = v.Email, Phone = v.Phone, CompanyPhone = v.CompanyPhone, + PreferredContact = v.PreferredContact, Address = v.Address, City = v.City, State = v.State, @@ -298,5 +300,83 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Vendors.CountAsync(); } + + public async Task GetCompanyByIdAsync(int id) + { + return await _context.VendorCompanies + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == id); + } + + public async Task GetCompanyByNormalizedNameAsync(string normalizedName) + { + return await _context.VendorCompanies + .AsNoTracking() + .FirstOrDefaultAsync(c => c.NormalizedName == normalizedName); + } + + public async Task AddCompanyAsync(VendorCompany company) + { + await _context.VendorCompanies.AddAsync(company); + await _context.SaveChangesAsync(); + return company; + } + + internal IQueryable BuildOpenLinkedWorkOrdersQuery(int vendorId) + { + var directLinks = _context.Dispatches + .Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue) + .Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value }); + + var multiLinks = _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && link.Dispatch.VendorId == vendorId) + .Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId }); + + var linkedIds = directLinks.Concat(multiLinks); + + return from link in linkedIds + join wo in _context.workOrders on link.WorkOrderId equals wo.Id + orderby wo.Id, link.DispatchId + select new LinkedWorkOrderInfo + { + WorkOrderId = wo.Id, + WorkOrderNumber = wo.WorkerOrderNumber, + WorkOrderTitle = wo.WorkerOrderTitle, + Status = wo.Status, + LifecycleStatus = wo.LifecycleStatus, + ScheduledDate = wo.ScheduledDate, + DispatchId = link.DispatchId + }; + } + + public async Task> GetOpenLinkedWorkOrdersAsync(int vendorId) + { + var raw = await BuildOpenLinkedWorkOrdersQuery(vendorId).ToListAsync(); + return raw.DistinctBy(x => x.WorkOrderId).ToList(); + } + + public async Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId) + { + var hasDirect = await _context.Dispatches + .AnyAsync(d => d.VendorId == vendorId && d.WorkOrderId == workOrderId); + + if (hasDirect) + return true; + + return await _context.DispatchWorkOrders + .AnyAsync(link => link.WorkOrderId == workOrderId + && link.Dispatch != null + && link.Dispatch.VendorId == vendorId); + } + + public async Task UpdateWithAuditLogsAsync( + Vendor vendor, + IReadOnlyCollection auditLogs) + { + _context.Vendors.Update(vendor); + if (auditLogs.Count > 0) + await _context.VendorAuditLogs.AddRangeAsync(auditLogs); + await _context.SaveChangesAsync(); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 2dbd0c1..6f5a469 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -23,5 +23,12 @@ namespace SeaHaven.DataServices.Interfaces Task GetActiveTokenAsync(int vendorId); Task GetTokenByStringAsync(string token); Task CountAsync(); + + Task GetCompanyByIdAsync(int id); + Task GetCompanyByNormalizedNameAsync(string normalizedName); + Task AddCompanyAsync(VendorCompany company); + Task> GetOpenLinkedWorkOrdersAsync(int vendorId); + Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId); + Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection auditLogs); } } diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 6c1cd82..780b724 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -4,10 +4,12 @@ namespace SeaHaven.Services.DTOs { public int Id { get; set; } public string? Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -22,10 +24,12 @@ namespace SeaHaven.Services.DTOs public class CreateVendorDTO { public required string Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -39,10 +43,12 @@ namespace SeaHaven.Services.DTOs public class UpdateVendorDTO { public string? Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -52,4 +58,42 @@ namespace SeaHaven.Services.DTOs public string? Notes { get; set; } public bool? IsActive { get; set; } } + + public class VendorDeactivationImpactDTO + { + public int VendorId { get; set; } + public bool CanDeactivate { get; set; } + public List OpenWorkOrders { get; set; } = new(); + } + + public class LinkedWorkOrderDTO + { + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public string? WorkOrderTitle { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? DispatchId { get; set; } + } + + public class WorkOrderVendorUpdateDTO + { + public int WorkOrderId { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Notes { get; set; } + } + + public class VendorDeactivationBlockedException : InvalidOperationException + { + public List OpenWorkOrders { get; } + + public VendorDeactivationBlockedException(string message, List openWorkOrders) + : base(message) + { + OpenWorkOrders = openWorkOrders; + } + } } diff --git a/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs b/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs new file mode 100644 index 0000000..5254184 --- /dev/null +++ b/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs @@ -0,0 +1,25 @@ +using System.Text.RegularExpressions; + +namespace SeaHaven.Services.Helpers +{ + public static class VendorPhoneNormalizer + { + public static readonly Regex NorthAmericanPhoneRegex = new( + @"^\(\d{3}\) \d{3}-\d{4}$", + RegexOptions.Compiled); + + public static string? NormalizeToCanonical(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var digits = new string(value.Where(char.IsDigit).ToArray()); + if (digits.Length == 11 && digits.StartsWith('1')) + digits = digits[1..]; + + return digits.Length == 10 + ? $"({digits[..3]}) {digits[3..6]}-{digits[6..]}" + : value; + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 6ba8e71..39b20c9 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -1,30 +1,35 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentValidation; +using FluentValidation.Results; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; namespace SeaHaven.Services.Implementation { - /// - /// Vendor service implementation - /// Contains business logic, validation, and orchestration for Vendor operations - /// public class VendorService : IVendorService { + private static readonly string[] TerminalLegacyStatuses = + { "completed", "cancelled", "canceled" }; + private readonly IVendorDataService _vendorDataService; private readonly ICreateVendorValidation _createValidator; private readonly IUpdateVendorValidation _updateValidator; + private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; public VendorService( IVendorDataService vendorDataService, ICreateVendorValidation createValidator, - IUpdateVendorValidation updateValidator) + IUpdateVendorValidation updateValidator, + IWorkOrderVendorUpdateValidation workOrderUpdateValidator) { _vendorDataService = vendorDataService; _createValidator = createValidator; _updateValidator = updateValidator; + _workOrderUpdateValidator = workOrderUpdateValidator; } public async Task GetVendorByIdAsync(int id) @@ -81,13 +86,16 @@ namespace SeaHaven.Services.Implementation throw new ValidationException(validationResult.Errors); } + var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId); + var vendor = new Vendor { CompanyName = dto.Name, ContactName = dto.ContactName, Email = dto.Email, - Phone = dto.Phone, - CompanyPhone = dto.CompanyPhone, + Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone), + CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone), + PreferredContact = dto.PreferredContact, Address = dto.Address, City = dto.City, State = dto.State, @@ -96,10 +104,13 @@ namespace SeaHaven.Services.Implementation GoogleMapsUrl = dto.GoogleMapsUrl, Notes = dto.Notes, IsActive = dto.IsActive, + CompanyId = company?.Id, CreatedDate = DateTime.UtcNow, createdby = userId }; + ApplyCompanyFields(vendor, company); + var created = await _vendorDataService.AddAsync(vendor); return MapToDTO(created); } @@ -116,11 +127,15 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); + if (dto.IsActive.HasValue && !dto.IsActive.Value) + await AssertNoOpenLinkedWorkOrdersAsync(id); + if (dto.Name != null) vendor.CompanyName = dto.Name; if (dto.ContactName != null) vendor.ContactName = dto.ContactName; if (dto.Email != null) vendor.Email = dto.Email; - if (dto.Phone != null) vendor.Phone = dto.Phone; - if (dto.CompanyPhone != null) vendor.CompanyPhone = dto.CompanyPhone; + if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + if (dto.CompanyPhone != null) vendor.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); + if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact; if (dto.Address != null) vendor.Address = dto.Address; if (dto.City != null) vendor.City = dto.City; if (dto.State != null) vendor.State = dto.State; @@ -130,6 +145,13 @@ namespace SeaHaven.Services.Implementation if (dto.Notes != null) vendor.Notes = dto.Notes; if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value; + if (dto.CompanyId.HasValue || dto.Name != null) + { + var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId); + vendor.CompanyId = company?.Id; + ApplyCompanyFields(vendor, company); + } + vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) vendor.LastModifierUserId = userIdInt; @@ -144,6 +166,8 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); + await AssertNoOpenLinkedWorkOrdersAsync(id); + vendor.IsActive = false; vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) @@ -162,16 +186,243 @@ namespace SeaHaven.Services.Implementation return await _vendorDataService.CountAsync(); } + public async Task GetDeactivationImpactAsync(int vendorId) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + throw new InvalidOperationException($"Vendor with ID {vendorId} not found"); + + var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var openWorkOrders = linked + .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) + .Select(MapToLinkedWorkOrderDTO) + .ToList(); + + return new VendorDeactivationImpactDTO + { + VendorId = vendorId, + CanDeactivate = openWorkOrders.Count == 0, + OpenWorkOrders = openWorkOrders + }; + } + + public async Task UpdateVendorFromWorkOrderAsync( + int vendorId, + WorkOrderVendorUpdateDTO dto, + string userId) + { + var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto); + if (!validationResult.IsValid) + { + throw new ValidationException(validationResult.Errors); + } + + var vendor = await _vendorDataService.GetByIdAsync(vendorId); + if (vendor == null) + throw new InvalidOperationException($"Vendor with ID {vendorId} not found"); + + var isAssigned = await _vendorDataService.IsVendorAssignedToWorkOrderAsync(vendorId, dto.WorkOrderId); + if (!isAssigned) + throw new InvalidOperationException( + $"Vendor {vendorId} is not actively linked to work order {dto.WorkOrderId}."); + + var changes = new List(); + var now = DateTime.UtcNow; + + void RecordChange(string fieldName, string? oldValue, string? newValue) + { + if (!string.Equals(oldValue, newValue, StringComparison.Ordinal)) + { + changes.Add(new VendorAuditLog + { + VendorId = vendorId, + WorkOrderId = dto.WorkOrderId, + FieldName = fieldName, + OldValue = oldValue, + NewValue = newValue, + Actor = userId, + CreatedAt = now + }); + } + } + + if (dto.ContactName != null) + { + RecordChange(nameof(Vendor.ContactName), vendor.ContactName, dto.ContactName); + vendor.ContactName = dto.ContactName; + } + + if (dto.PreferredContact != null) + { + RecordChange(nameof(Vendor.PreferredContact), vendor.PreferredContact, dto.PreferredContact); + vendor.PreferredContact = dto.PreferredContact; + } + + if (dto.Phone != null) + { + var normalized = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + RecordChange(nameof(Vendor.Phone), vendor.Phone, normalized); + vendor.Phone = normalized; + } + + if (dto.Email != null) + { + RecordChange(nameof(Vendor.Email), vendor.Email, dto.Email); + vendor.Email = dto.Email; + } + + if (dto.Notes != null) + { + RecordChange(nameof(Vendor.Notes), vendor.Notes, dto.Notes); + vendor.Notes = dto.Notes; + } + + vendor.LastModificationTime = now; + if (int.TryParse(userId, out int userIdInt)) + vendor.LastModifierUserId = userIdInt; + + await _vendorDataService.UpdateWithAuditLogsAsync(vendor, changes); + + return MapToDTO(vendor); + } + + private async Task ResolveCompanyAsync( + int? requestedCompanyId, + string? companyName, + object dto, + string userId) + { + if (requestedCompanyId.HasValue) + { + var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value); + if (byId != null) + return byId; + + throw new ValidationException(new[] + { + new ValidationFailure( + nameof(CreateVendorDTO.CompanyId), + $"No vendor company exists with ID {requestedCompanyId.Value}.") + }); + } + + if (string.IsNullOrWhiteSpace(companyName)) + return null; + + var normalizedName = companyName.Trim().ToLowerInvariant(); + var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName); + if (existing != null) + return existing; + + string? companyPhone = null; + string? email = null; + string? address = null; + string? city = null; + string? state = null; + string? zip = null; + string? mapsUrl = null; + + if (dto is CreateVendorDTO create) + { + companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(create.CompanyPhone); + email = create.Email; + address = create.Address; + city = create.City; + state = create.State; + zip = create.Zipcode; + mapsUrl = create.GoogleMapsUrl; + } + else if (dto is UpdateVendorDTO update) + { + companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(update.CompanyPhone); + email = update.Email; + address = update.Address; + city = update.City; + state = update.State; + zip = update.Zipcode; + mapsUrl = update.GoogleMapsUrl; + } + + var company = new VendorCompany + { + Name = companyName.Trim(), + NormalizedName = normalizedName, + CompanyPhone = companyPhone, + Email = email, + Address = address, + City = city, + State = state, + Zip = zip, + GoogleMapsUrl = mapsUrl, + CreatedDate = DateTime.UtcNow, + createdby = userId + }; + + return await _vendorDataService.AddCompanyAsync(company); + } + + private static void ApplyCompanyFields(Vendor vendor, VendorCompany? company) + { + if (company == null) + return; + + vendor.CompanyPhone = company.CompanyPhone; + vendor.Address = company.Address; + vendor.City = company.City; + vendor.State = company.State; + vendor.Zip = company.Zip; + vendor.GoogleMapsUrl = company.GoogleMapsUrl; + } + + private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId) + { + var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var openWorkOrders = linked + .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) + .Select(MapToLinkedWorkOrderDTO) + .ToList(); + + if (openWorkOrders.Count > 0) + { + throw new VendorDeactivationBlockedException( + "Vendor cannot be deactivated while linked work orders are open or scheduled.", + openWorkOrders); + } + } + + private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus) + { + if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled) + return true; + + if (!string.IsNullOrWhiteSpace(legacyStatus) && + TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant())) + return true; + + return false; + } + + private static LinkedWorkOrderDTO MapToLinkedWorkOrderDTO(LinkedWorkOrderInfo info) => new() + { + WorkOrderId = info.WorkOrderId, + WorkOrderNumber = info.WorkOrderNumber, + WorkOrderTitle = info.WorkOrderTitle, + Status = info.Status, + ScheduledDate = info.ScheduledDate, + DispatchId = info.DispatchId + }; + private VendorDTO MapToDTO(Vendor vendor) { return new VendorDTO { Id = vendor.Id, Name = vendor.CompanyName, + CompanyId = vendor.CompanyId, ContactName = vendor.ContactName, Email = vendor.Email, Phone = vendor.Phone, CompanyPhone = vendor.CompanyPhone, + PreferredContact = vendor.PreferredContact, Address = vendor.Address, City = vendor.City, State = vendor.State, diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index aad7b13..2ab4c67 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -21,5 +21,7 @@ namespace SeaHaven.Services.Interfaces Task DeleteVendorAsync(int id, string userId); Task VendorExistsAsync(int id); Task GetTotalVendorCountAsync(); + Task GetDeactivationImpactAsync(int vendorId); + Task UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId); } } diff --git a/SeaHaven.Services/Validation/VendorValidation.cs b/SeaHaven.Services/Validation/VendorValidation.cs index acce074..5ee2c06 100644 --- a/SeaHaven.Services/Validation/VendorValidation.cs +++ b/SeaHaven.Services/Validation/VendorValidation.cs @@ -1,5 +1,6 @@ using FluentValidation; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; namespace SeaHaven.Services.Validation { @@ -23,16 +24,23 @@ namespace SeaHaven.Services.Validation .NotEmpty().WithMessage("Technician name is required") .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters"); + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + RuleFor(x => x.TradeSpecialties) .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); RuleFor(x => x.Phone) - .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.Phone)); RuleFor(x => x.CompanyPhone) - .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); RuleFor(x => x.Address) @@ -82,16 +90,23 @@ namespace SeaHaven.Services.Validation .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") .When(x => x.ContactName != null); + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + RuleFor(x => x.TradeSpecialties) .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); RuleFor(x => x.Phone) - .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.Phone)); RuleFor(x => x.CompanyPhone) - .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); RuleFor(x => x.Address) @@ -121,9 +136,52 @@ namespace SeaHaven.Services.Validation } + public interface IWorkOrderVendorUpdateValidation : IValidator + { + } + + public class WorkOrderVendorUpdateValidation : AbstractValidator, IWorkOrderVendorUpdateValidation + { + public WorkOrderVendorUpdateValidation() + { + RuleFor(x => x.WorkOrderId) + .GreaterThan(0).WithMessage("A valid work order id is required"); + + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + + RuleFor(x => x.Phone) + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") + .When(x => !string.IsNullOrEmpty(x.Phone)); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Invalid email address") + .When(x => !string.IsNullOrEmpty(x.Email)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); + + RuleFor(x => x.ContactName) + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") + .When(x => !string.IsNullOrEmpty(x.ContactName)); + } + } + internal static class VendorValidationRules { + private static readonly string[] AllowedPreferredContacts = { "Phone", "Email", "Text" }; + internal static bool BeValidHttpsUrl(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; + + internal static bool BeValidPreferredContact(string? value) => + AllowedPreferredContacts.Contains(value, StringComparer.OrdinalIgnoreCase); + + internal static bool BeValidNorthAmericanPhone(string? value) => + value is not null && VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(value); } } From 5b4b538c16e6d07523298ffc140591d667467def Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 19:17:14 -0300 Subject: [PATCH 15/30] fix(vendors): preserve company and phone integrity --- .../VendorDataServiceTests.cs | 8 +- .../VendorServiceTests.cs | 103 +++++++++++++++++- .../Implementation/VendorDataService.cs | 29 ++++- .../Interfaces/IVendorDataService.cs | 3 +- .../Implementation/VendorService.cs | 97 ++++++++++++++--- 5 files changed, 209 insertions(+), 31 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 80057f5..482e4bb 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -269,12 +269,12 @@ public class VendorDataServiceTests } [Fact] - public void BuildOpenLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation() + public void BuildLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation() { using var context = NewSqlServerContext(); var service = new VendorDataService(context); - var sql = service.BuildOpenLinkedWorkOrdersQuery(60).ToQueryString(); + var sql = service.BuildLinkedWorkOrdersQuery(60).ToQueryString(); sql.Should().Contain("SELECT"); sql.Should().Contain("Dispatches"); @@ -283,7 +283,7 @@ public class VendorDataServiceTests } [Fact] - public async Task GetOpenLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks() + public async Task GetLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks() { await using var context = NewContext(); var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true }; @@ -315,7 +315,7 @@ public class VendorDataServiceTests await context.SaveChangesAsync(); var service = new VendorDataService(context); - var result = await service.GetOpenLinkedWorkOrdersAsync(60); + var result = await service.GetLinkedWorkOrdersAsync(60); result.Should().HaveCount(2); result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 }); diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 9c49893..315bd18 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -58,7 +58,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); + data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO @@ -83,7 +83,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); + data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); await NewService(data).DeleteVendorAsync(9, "42"); @@ -221,7 +221,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(20)) + data.Setup(x => x.GetLinkedWorkOrdersAsync(20)) .ReturnsAsync(new List { new() @@ -283,7 +283,7 @@ public class VendorServiceTests } [Fact] - public void CreateVendor_RejectsNonCanonicalPhoneAndInvalidPreferredContact() + public void CreateVendor_RejectsInvalidPhoneAndInvalidPreferredContact() { var validator = new CreateVendorValidation(); @@ -300,6 +300,99 @@ public class VendorServiceTests result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact)); } + [Fact] + public async Task CreateVendor_NormalizesCommonNorthAmericanPhoneFormatsBeforeValidation() + { + var data = new Mock(); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((Vendor vendor) => + { + saved = vendor; + vendor.Id = 41; + return vendor; + }); + + await NewService(data).CreateVendorAsync(new CreateVendorDTO + { + Name = "Acme", + ContactName = "Riley", + Phone = "+1 312 555 0100" + }, "42"); + + saved!.Phone.Should().Be("(312) 555-0100"); + } + + [Fact] + public async Task UpdateVendor_RoutesCompanyOwnedFieldsToTheLinkedCompany() + { + var existing = new Vendor + { + Id = 42, + CompanyId = 77, + CompanyName = "Acme", + Address = "Old address", + IsActive = true + }; + var company = new VendorCompany + { + Id = 77, + Name = "Acme", + NormalizedName = "acme", + Address = "Old address" + }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(existing); + data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company); + data.Setup(x => x.UpdateCompanyAsync(company)).Returns(Task.CompletedTask); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).UpdateVendorAsync(42, new UpdateVendorDTO + { + Name = "Acme Services", + Address = "200 New Street" + }, "42"); + + company.Name.Should().Be("Acme Services"); + company.NormalizedName.Should().Be("acme services"); + company.Address.Should().Be("200 New Street"); + existing.Address.Should().Be("200 New Street"); + data.Verify(x => x.UpdateCompanyAsync(company), Times.Once); + } + + [Fact] + public async Task UpdateVendor_SparseCompanyDoesNotEraseExistingVendorFields() + { + var existing = new Vendor + { + Id = 43, + CompanyId = 77, + CompanyName = "Acme", + Address = "Keep this address", + CompanyPhone = "(312) 555-0199", + IsActive = true + }; + var sparseCompany = new VendorCompany + { + Id = 77, + Name = "Acme", + NormalizedName = "acme" + }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(existing); + data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(sparseCompany); + data.Setup(x => x.UpdateCompanyAsync(sparseCompany)).Returns(Task.CompletedTask); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).UpdateVendorAsync(43, new UpdateVendorDTO + { + Name = "Acme" + }, "42"); + + existing.Address.Should().Be("Keep this address"); + existing.CompanyPhone.Should().Be("(312) 555-0199"); + } + [Fact] public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact() { @@ -383,6 +476,6 @@ public class VendorServiceTests await act.Should().ThrowAsync() .WithMessage("*404*not found"); - data.Verify(x => x.GetOpenLinkedWorkOrdersAsync(It.IsAny()), Times.Never); + data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny()), Times.Never); } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index e89620b..541c89f 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -318,11 +318,30 @@ namespace SeaHaven.DataServices.Implementation public async Task AddCompanyAsync(VendorCompany company) { await _context.VendorCompanies.AddAsync(company); - await _context.SaveChangesAsync(); - return company; + try + { + await _context.SaveChangesAsync(); + return company; + } + catch (DbUpdateException) + { + _context.Entry(company).State = EntityState.Detached; + var existing = await _context.VendorCompanies + .AsNoTracking() + .SingleOrDefaultAsync(c => c.NormalizedName == company.NormalizedName); + if (existing != null) + return existing; + throw; + } } - internal IQueryable BuildOpenLinkedWorkOrdersQuery(int vendorId) + public async Task UpdateCompanyAsync(VendorCompany company) + { + _context.VendorCompanies.Update(company); + await _context.SaveChangesAsync(); + } + + internal IQueryable BuildLinkedWorkOrdersQuery(int vendorId) { var directLinks = _context.Dispatches .Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue) @@ -349,9 +368,9 @@ namespace SeaHaven.DataServices.Implementation }; } - public async Task> GetOpenLinkedWorkOrdersAsync(int vendorId) + public async Task> GetLinkedWorkOrdersAsync(int vendorId) { - var raw = await BuildOpenLinkedWorkOrdersQuery(vendorId).ToListAsync(); + var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync(); return raw.DistinctBy(x => x.WorkOrderId).ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 6f5a469..b0acd6d 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -27,7 +27,8 @@ namespace SeaHaven.DataServices.Interfaces Task GetCompanyByIdAsync(int id); Task GetCompanyByNormalizedNameAsync(string normalizedName); Task AddCompanyAsync(VendorCompany company); - Task> GetOpenLinkedWorkOrdersAsync(int vendorId); + Task UpdateCompanyAsync(VendorCompany company); + Task> GetLinkedWorkOrdersAsync(int vendorId); Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId); Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection auditLogs); } diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 39b20c9..009a9e4 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -80,6 +80,7 @@ namespace SeaHaven.Services.Implementation public async Task CreateVendorAsync(CreateVendorDTO dto, string userId) { + NormalizePhoneFields(dto); var validationResult = await _createValidator.ValidateAsync(dto); if (!validationResult.IsValid) { @@ -117,6 +118,7 @@ namespace SeaHaven.Services.Implementation public async Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId) { + NormalizePhoneFields(dto); var validationResult = await _updateValidator.ValidateAsync(dto); if (!validationResult.IsValid) { @@ -134,20 +136,18 @@ namespace SeaHaven.Services.Implementation if (dto.ContactName != null) vendor.ContactName = dto.ContactName; if (dto.Email != null) vendor.Email = dto.Email; if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); - if (dto.CompanyPhone != null) vendor.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact; - if (dto.Address != null) vendor.Address = dto.Address; - if (dto.City != null) vendor.City = dto.City; - if (dto.State != null) vendor.State = dto.State; - if (dto.Zipcode != null) vendor.Zip = dto.Zipcode; if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties; - if (dto.GoogleMapsUrl != null) vendor.GoogleMapsUrl = dto.GoogleMapsUrl; if (dto.Notes != null) vendor.Notes = dto.Notes; if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value; - if (dto.CompanyId.HasValue || dto.Name != null) + if (dto.CompanyId.HasValue || dto.Name != null || HasCompanyFieldUpdate(dto)) { - var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId); + var company = await ResolveCompanyAsync( + dto.CompanyId ?? vendor.CompanyId, + dto.Name ?? vendor.CompanyName, + dto, + userId); vendor.CompanyId = company?.Id; ApplyCompanyFields(vendor, company); } @@ -191,7 +191,7 @@ namespace SeaHaven.Services.Implementation if (!await _vendorDataService.ExistsAsync(vendorId)) throw new InvalidOperationException($"Vendor with ID {vendorId} not found"); - var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId); var openWorkOrders = linked .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) .Select(MapToLinkedWorkOrderDTO) @@ -210,6 +210,7 @@ namespace SeaHaven.Services.Implementation WorkOrderVendorUpdateDTO dto, string userId) { + dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto); if (!validationResult.IsValid) { @@ -295,7 +296,11 @@ namespace SeaHaven.Services.Implementation { var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value); if (byId != null) + { + ApplyCompanyUpdates(byId, companyName, dto, userId); + await _vendorDataService.UpdateCompanyAsync(byId); return byId; + } throw new ValidationException(new[] { @@ -311,7 +316,11 @@ namespace SeaHaven.Services.Implementation var normalizedName = companyName.Trim().ToLowerInvariant(); var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName); if (existing != null) + { + ApplyCompanyUpdates(existing, companyName, dto, userId); + await _vendorDataService.UpdateCompanyAsync(existing); return existing; + } string? companyPhone = null; string? email = null; @@ -365,17 +374,73 @@ namespace SeaHaven.Services.Implementation if (company == null) return; - vendor.CompanyPhone = company.CompanyPhone; - vendor.Address = company.Address; - vendor.City = company.City; - vendor.State = company.State; - vendor.Zip = company.Zip; - vendor.GoogleMapsUrl = company.GoogleMapsUrl; + if (company.CompanyPhone != null) vendor.CompanyPhone = company.CompanyPhone; + if (company.Address != null) vendor.Address = company.Address; + if (company.City != null) vendor.City = company.City; + if (company.State != null) vendor.State = company.State; + if (company.Zip != null) vendor.Zip = company.Zip; + if (company.GoogleMapsUrl != null) vendor.GoogleMapsUrl = company.GoogleMapsUrl; + } + + private static bool HasCompanyFieldUpdate(UpdateVendorDTO dto) => + dto.CompanyPhone != null || + dto.Address != null || + dto.City != null || + dto.State != null || + dto.Zipcode != null || + dto.GoogleMapsUrl != null; + + private static void ApplyCompanyUpdates( + VendorCompany company, + string? companyName, + object dto, + string userId) + { + if (!string.IsNullOrWhiteSpace(companyName)) + { + company.Name = companyName.Trim(); + company.NormalizedName = company.Name.ToLowerInvariant(); + } + + if (dto is CreateVendorDTO create) + { + if (create.CompanyPhone != null) company.CompanyPhone = create.CompanyPhone; + if (create.Address != null) company.Address = create.Address; + if (create.City != null) company.City = create.City; + if (create.State != null) company.State = create.State; + if (create.Zipcode != null) company.Zip = create.Zipcode; + if (create.GoogleMapsUrl != null) company.GoogleMapsUrl = create.GoogleMapsUrl; + } + else if (dto is UpdateVendorDTO update) + { + if (update.CompanyPhone != null) company.CompanyPhone = update.CompanyPhone; + if (update.Address != null) company.Address = update.Address; + if (update.City != null) company.City = update.City; + if (update.State != null) company.State = update.State; + if (update.Zipcode != null) company.Zip = update.Zipcode; + if (update.GoogleMapsUrl != null) company.GoogleMapsUrl = update.GoogleMapsUrl; + } + + company.LastModificationTime = DateTime.UtcNow; + if (int.TryParse(userId, out int userIdInt)) + company.LastModifierUserId = userIdInt; + } + + private static void NormalizePhoneFields(CreateVendorDTO dto) + { + dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); + } + + private static void NormalizePhoneFields(UpdateVendorDTO dto) + { + dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); } private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId) { - var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId); var openWorkOrders = linked .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) .Select(MapToLinkedWorkOrderDTO) From 4863d1fdaf3f15d1705fcf2fe13e1648fe5d550d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 19:18:06 -0300 Subject: [PATCH 16/30] feat(vendors): complete operations roadmap backend --- .../VendorOperationsControllerTests.cs | 313 ++ .../VendorPortalDocumentTests.cs | 134 + .../Controllers/VendorOperationsController.cs | 724 ++++ .../Controllers/VendorPortalController.cs | 178 +- .../Controllers/WorkOrderController.cs | 41 +- Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs | 2 + .../DTOs/UpdateDispatch_DTO.cs | 2 + .../Helper/ClamAvDocumentScanner.cs | 70 + .../Helper/VendorDocumentScanWorker.cs | 75 + Api.SeaHavenIndustries/Program.cs | 3 + .../Auth/ApplicationDbContext.cs | 12 + ...614_AddVendorRoadmapOperations.Designer.cs | 3441 +++++++++++++++++ ...260723220614_AddVendorRoadmapOperations.cs | 230 ++ .../ApplicationDbContextModelSnapshot.cs | 303 ++ Data.SeaHavenIndustries/Models/Dispatch.cs | 7 + .../Models/SitePreferredVendor.cs | 19 + Data.SeaHavenIndustries/Models/Vendor.cs | 3 + .../Models/VendorCompletionDocument.cs | 31 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 1 + 19 files changed, 5587 insertions(+), 2 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs create mode 100644 Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs create mode 100644 Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs create mode 100644 Data.SeaHavenIndustries/Models/SitePreferredVendor.cs create mode 100644 Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs new file mode 100644 index 0000000..d7d7bec --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs @@ -0,0 +1,313 @@ +using System.Collections; +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorOperationsControllerTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static VendorOperationsController NewController(ApplicationDbContext context) + { + var controller = new VendorOperationsController( + context, + new DispatchDataService(context), + TimeProvider.System); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test")) + } + }; + return controller; + } + + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + [Fact] + public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours() + { + using var context = NewContext(); + context.workOrders.Add(new WorkOrder + { + InternalWONumber = "WO-48", + WorkerOrderTitle = "Needs vendor", + ScheduledDate = DateTime.UtcNow.AddHours(47) + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetNotifications(); + + var value = ((OkObjectResult)result).Value!; + var items = ((IEnumerable)Prop(value, "Items")).Cast().ToList(); + items.Should().ContainSingle(); + Prop(items[0], "Type").Should().Be("NoVendor"); + Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service"); + } + + [Fact] + public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-ASSIGNED", + ScheduledDate = DateTime.UtcNow.AddHours(24) + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Sent", + ScheduledDate = workOrder.ScheduledDate + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetNotifications(); + + var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast(); + items.Should().BeEmpty(); + } + + [Fact] + public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst() + { + using var context = NewContext(); + var site = new Locations { Name = "St Louis" }; + var preferred = new Vendor + { + CompanyName = "Preferred", + ContactName = "Alex", + IsActive = true, + TradeSpecialties = "HVAC", + AvailabilityStatus = "Available", + AvailabilityUpdatedAt = DateTime.UtcNow + }; + var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-BUSY", + ScheduledStart = DateTime.UtcNow.AddHours(1), + ScheduledEnd = DateTime.UtcNow.AddHours(3) + }; + context.AddRange(site, preferred, busy, workOrder); + await context.SaveChangesAsync(); + context.SitePreferredVendors.Add(new SitePreferredVendor + { + LocationId = site.Id, + VendorId = preferred.Id, + Trade = "HVAC", + SortOrder = 0 + }); + context.Dispatches.Add(new Dispatch + { + VendorId = busy.Id, + WorkOrderId = workOrder.Id, + Status = "Sent", + ScheduledDate = workOrder.ScheduledStart + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetAvailability( + DateTime.UtcNow.AddHours(2), + DateTime.UtcNow.AddHours(4), + site.Id, + "HVAC"); + + var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast().ToList(); + Prop(rows[0], "CompanyName").Should().Be("Preferred"); + Prop(rows[0], "IsPreferred").Should().Be(true); + var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy"); + Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable"); + } + + [Fact] + public async Task ReplaceSitePreferences_RejectsInactiveVendor() + { + using var context = NewContext(); + var site = new Locations { Name = "St Louis" }; + var inactive = new Vendor { CompanyName = "Inactive", IsActive = false }; + context.AddRange(site, inactive); + await context.SaveChangesAsync(); + + var result = await NewController(context).ReplaceSitePreferences(site.Id, + new List + { + new() { VendorId = inactive.Id } + }); + + result.Should().BeOfType(); + context.SitePreferredVendors.Should().BeEmpty(); + } + + [Fact] + public async Task InsightsExports_ReturnStableCsvAndPdfFormats() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false }; + var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Completed", + DispatchedAt = DateTime.UtcNow.AddHours(-4), + ScheduledDate = DateTime.UtcNow.AddHours(-2), + AcknowledgedAt = DateTime.UtcNow.AddHours(-3), + CompletedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var controller = NewController(context); + + var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null); + var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null); + + csv.ContentType.Should().Be("text/csv"); + var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents); + csvText.Should().Contain("AverageCycleHours"); + csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\""); + pdf.ContentType.Should().Be("application/pdf"); + System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4"); + } + + [Fact] + public async Task AssignVendor_RejectsInactiveVendor() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Inactive", IsActive = false }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + + var result = await NewController(context).AssignVendor( + workOrder.Id, + new VendorOperationsController.VendorAssignmentRequest { VendorId = vendor.Id }); + + result.Should().BeOfType(); + context.Dispatches.Should().BeEmpty(); + } + + [Fact] + public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts() + { + using var context = NewContext(); + var start = DateTime.UtcNow.AddHours(2); + var vendor = new Vendor + { + CompanyName = "Gateway", + IsActive = true, + AvailabilityStatus = "Available", + AvailabilityUpdatedAt = DateTime.UtcNow + }; + var existingWorkOrder = new WorkOrder + { + InternalWONumber = "WO-EXISTING", + ScheduledStart = start, + ScheduledEnd = start.AddHours(2) + }; + var assignedWorkOrder = new WorkOrder + { + InternalWONumber = "WO-NEW", + ScheduledStart = start.AddMinutes(30), + ScheduledEnd = start.AddHours(1) + }; + context.AddRange(vendor, existingWorkOrder, assignedWorkOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = existingWorkOrder.Id, + Status = "Sent", + ScheduledDate = existingWorkOrder.ScheduledStart + }); + await context.SaveChangesAsync(); + var eta = start.AddMinutes(45); + + var result = await NewController(context).AssignVendor( + assignedWorkOrder.Id, + new VendorOperationsController.VendorAssignmentRequest + { + VendorId = vendor.Id, + ScheduledStart = assignedWorkOrder.ScheduledStart, + ScheduledEnd = assignedWorkOrder.ScheduledEnd, + EstimatedArrivalAt = eta, + EtaManualOverride = true + }); + + var value = ((OkObjectResult)result).Value!; + Prop(value, "AvailabilityStatus").Should().Be("Unavailable"); + ((IEnumerable)Prop(value, "Conflicts")).Cast().Should().ContainSingle(); + var dispatch = await context.Dispatches.SingleAsync(item => + item.WorkOrderId == assignedWorkOrder.Id); + dispatch.EstimatedArrivalAt.Should().Be(eta); + dispatch.EtaManualOverride.Should().BeTrue(); + dispatch.DispatchNumber.Should().StartWith("DISP-"); + context.WorkOrderAuditLogs.Should().ContainSingle(log => + log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned"); + } + + [Fact] + public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin() + { + using var context = NewContext(); + var originalVendor = new Vendor { CompanyName = "Original", IsActive = true }; + var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-LINKED", + ScheduledStart = DateTime.UtcNow.AddHours(2), + ScheduledEnd = DateTime.UtcNow.AddHours(4) + }; + context.AddRange(originalVendor, replacementVendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = originalVendor.Id, + Status = "Sent", + DispatchNumber = "DISP-LINKED" + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).AssignVendor( + workOrder.Id, + new VendorOperationsController.VendorAssignmentRequest + { + VendorId = replacementVendor.Id, + ScheduledStart = workOrder.ScheduledStart, + ScheduledEnd = workOrder.ScheduledEnd + }); + + result.Should().BeOfType(); + context.Dispatches.Should().ContainSingle(); + (await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs new file mode 100644 index 0000000..e6b6167 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -0,0 +1,134 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.Helper; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class VendorPortalDocumentTests : IDisposable +{ + private const string Token = "vendor-document-test-token"; + private readonly string _contentRoot = Path.Combine( + Path.GetTempPath(), + $"seahaven-vendor-documents-{Guid.NewGuid():N}"); + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private VendorPortalController NewController(ApplicationDbContext context) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["VendorPortal:TokenLifetimeDays"] = "365", + ["SendGrid:ApiKey"] = "not-used" + }) + .Build(); + var environment = new Mock(); + environment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); + var controller = new VendorPortalController( + context, + new VendorPortalTokenService(context, configuration), + configuration, + new SendMessage(configuration), + environment.Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + controller.Request.Headers["X-Vendor-Token"] = Token; + return controller; + } + + private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch( + ApplicationDbContext context) + { + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Completed" + }; + context.Dispatches.Add(dispatch); + context.VendorAccessTokens.Add(new VendorAccessToken + { + VendorId = vendor.Id, + Token = Token, + IssuedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + await context.SaveChangesAsync(); + return (vendor, dispatch); + } + + private static FormFile FormFile(byte[] bytes, string fileName, string contentType) + { + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + [Fact] + public async Task UploadCompletionDocument_RejectsMismatchedFileSignature() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var controller = NewController(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + var upload = await controller.UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + upload.Should().BeOfType(); + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ScanStatus.Should().Be("Pending"); + document.ReviewStatus.Should().Be("Processing"); + File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue(); + + var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id); + + var locked = download.Should().BeOfType().Subject; + locked.StatusCode.Should().Be(StatusCodes.Status423Locked); + } + + public void Dispose() + { + if (Directory.Exists(_contentRoot)) + { + Directory.Delete(_contentRoot, recursive: true); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs b/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs new file mode 100644 index 0000000..5dd93cc --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs @@ -0,0 +1,724 @@ +using System.Security.Claims; +using System.Text; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/vendor-operations")] + public class VendorOperationsController : ControllerBase + { + private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" }; + private readonly ApplicationDbContext _db; + private readonly IDispatchDataService _dispatchDataService; + private readonly TimeProvider _timeProvider; + + public VendorOperationsController( + ApplicationDbContext db, + IDispatchDataService dispatchDataService, + TimeProvider timeProvider) + { + _db = db; + _dispatchDataService = dispatchDataService; + _timeProvider = timeProvider; + } + + [HttpGet("notifications")] + public async Task GetNotifications() + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var cutoff = now.AddHours(48); + + var directlyAssignedIds = await _db.Dispatches + .AsNoTracking() + .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue) + .Select(d => d.WorkOrderId!.Value) + .ToListAsync(); + var multiplyAssignedIds = await _db.DispatchWorkOrders + .AsNoTracking() + .Where(link => link.Dispatch != null + && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) + .Select(link => link.WorkOrderId) + .ToListAsync(); + var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList(); + + var noVendor = await _db.workOrders + .AsNoTracking() + .Where(wo => wo.ScheduledDate.HasValue + && wo.ScheduledDate.Value <= cutoff + && wo.LifecycleStatus != LifecycleStatus.Completed + && wo.LifecycleStatus != LifecycleStatus.Canceled + && !assignedWorkOrderIds.Contains(wo.Id)) + .OrderBy(wo => wo.ScheduledDate) + .Select(wo => new + { + Id = $"no-vendor-{wo.Id}", + Type = "NoVendor", + Severity = "Medium", + Group = "No Vendor", + Title = "No vendor assigned — 48h to service", + WorkOrderId = wo.Id, + WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber, + TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48), + ScheduledAt = wo.ScheduledDate, + Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" } + }) + .ToListAsync(); + + var dispatches = await ActiveScheduledDispatches().ToListAsync(); + var conflicts = BuildConflictPairs(dispatches) + .Select(pair => new + { + Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}", + Type = "VendorConflict", + Severity = "Medium", + Group = "Vendor Conflict", + Title = "Vendor has overlapping appointments", + VendorId = pair.Left.VendorId, + VendorName = pair.Left.VendorName, + WorkOrders = new[] + { + new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End }, + new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End } + }, + TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start, + Actions = new[] + { + new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" }, + new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" } + } + }) + .ToList(); + + return Ok(new + { + GeneratedAt = now, + Items = noVendor.Cast().Concat(conflicts).OrderBy(item => + item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList() + }); + } + + [HttpGet("availability")] + public async Task GetAvailability( + [FromQuery] DateTime? start, + [FromQuery] DateTime? end, + [FromQuery] int? locationId, + [FromQuery] string? trade) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var requestedStart = start ?? now; + var requestedEnd = end ?? requestedStart.AddHours(2); + if (requestedEnd <= requestedStart) + return BadRequest(new Response { Status = "Error", Message = "end must be after start" }); + + var vendors = await _db.Vendors.AsNoTracking() + .Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false)) + .ToListAsync(); + + var preferences = locationId.HasValue + ? await _db.SitePreferredVendors.AsNoTracking() + .Where(p => p.LocationId == locationId.Value) + .ToListAsync() + : new List(); + + var dispatches = await ActiveScheduledDispatches().ToListAsync(); + var result = vendors.Select(vendor => + { + var matchingDispatches = dispatches + .Where(dispatch => dispatch.VendorId == vendor.Id + && WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End)) + .ToList(); + var matchingPreference = preferences + .Where(preference => preference.VendorId == vendor.Id + && (string.IsNullOrWhiteSpace(preference.Trade) + || string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase))) + .OrderBy(preference => preference.SortOrder) + .FirstOrDefault(); + var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue + && vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24); + var status = matchingDispatches.Count > 0 + ? "Unavailable" + : signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus) + ? vendor.AvailabilityStatus + : "Unknown"; + + return new + { + vendor.Id, + vendor.CompanyName, + vendor.ContactName, + vendor.TradeSpecialties, + AvailabilityStatus = status, + vendor.AvailabilityUpdatedAt, + IsPreferred = matchingPreference != null, + PreferenceRank = matchingPreference?.SortOrder, + Conflicts = matchingDispatches.Select(dispatch => new + { + dispatch.WorkOrderId, + dispatch.WorkOrderNumber, + dispatch.Start, + dispatch.End + }) + }; + }) + .OrderBy(vendor => vendor.IsPreferred ? 0 : 1) + .ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue) + .ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0 + : vendor.AvailabilityStatus == "Unknown" ? 1 : 2) + .ThenBy(vendor => vendor.CompanyName) + .ToList(); + + return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result }); + } + + [HttpPut("vendors/{vendorId:int}/availability")] + public async Task UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body) + { + var normalized = body.Status?.Trim(); + if (normalized is not ("Available" or "Unavailable" or "Unknown")) + return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" }); + + var vendor = await _db.Vendors.FindAsync(vendorId); + if (vendor == null) return NotFound(); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var actor = Actor(); + var previous = vendor.AvailabilityStatus; + vendor.AvailabilityStatus = normalized; + vendor.AvailabilityUpdatedAt = now; + vendor.LastModificationTime = now; + _db.VendorAuditLogs.Add(new VendorAuditLog + { + VendorId = vendorId, + FieldName = nameof(Vendor.AvailabilityStatus), + OldValue = previous, + NewValue = normalized, + Actor = actor, + CreatedAt = now + }); + await _db.SaveChangesAsync(); + return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt }); + } + + [HttpGet("sites/{locationId:int}/preferred-vendors")] + public async Task GetSitePreferences(int locationId) + { + var preferences = await _db.SitePreferredVendors + .AsNoTracking() + .Where(p => p.LocationId == locationId) + .Include(p => p.Vendor) + .OrderBy(p => p.SortOrder) + .Select(p => new + { + p.Id, + p.LocationId, + p.VendorId, + VendorName = p.Vendor!.CompanyName, + p.Trade, + p.SortOrder, + IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false), + p.LastModificationTime, + p.UpdatedBy + }) + .ToListAsync(); + return Ok(preferences); + } + + [HttpPut("sites/{locationId:int}/preferred-vendors")] + public async Task ReplaceSitePreferences(int locationId, [FromBody] List body) + { + if (!await _db.Locations.AnyAsync(location => location.Id == locationId)) + return NotFound(new Response { Status = "Error", Message = "Location not found" }); + if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1)) + return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" }); + + var vendorIds = body.Select(item => item.VendorId).Distinct().ToList(); + var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id); + foreach (var item in body) + { + if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false)) + return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" }); + if (!string.IsNullOrWhiteSpace(item.Trade) + && !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase)) + return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" }); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var actor = Actor(); + var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync(); + _db.SitePreferredVendors.RemoveRange(existing); + _db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor + { + LocationId = locationId, + VendorId = item.VendorId, + Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(), + SortOrder = item.SortOrder ?? index, + CreatedDate = now, + LastModificationTime = now, + createdby = actor, + UpdatedBy = actor + })); + foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct()) + { + _db.VendorAuditLogs.Add(new VendorAuditLog + { + VendorId = vendorId, + FieldName = $"SitePreference:{locationId}", + OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), + NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), + Actor = actor, + CreatedAt = now + }); + } + await _db.SaveChangesAsync(); + return await GetSitePreferences(locationId); + } + + [HttpPut("documents/{documentId:int}/review")] + public async Task ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body) + { + if (body.ReviewStatus is not ("Approved" or "Rejected")) + return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" }); + if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason)) + return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" }); + + var document = await _db.VendorCompletionDocuments.FindAsync(documentId); + if (document == null) return NotFound(); + if (document.ScanStatus != "Passed") + return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" }); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var previous = document.ReviewStatus; + document.ReviewStatus = body.ReviewStatus; + document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null; + document.ReviewedAt = now; + document.LastModificationTime = now; + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = document.WorkOrderId, + UserId = Actor(), + FieldName = $"Completion Document {document.OriginalFileName}", + OldValue = previous, + NewValue = document.ReviewStatus, + Action = "completion_document_reviewed", + CreatedAt = now + }); + await _db.SaveChangesAsync(); + return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt }); + } + + [HttpPut("dispatches/{dispatchId:int}/commercial-status")] + public async Task UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body) + { + var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" }; + var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" }; + if (!invoiceStatuses.Contains(body.InvoiceStatus)) + return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" }); + if (!paymentStatuses.Contains(body.PaymentStatus)) + return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" }); + + var dispatch = await _db.Dispatches.FindAsync(dispatchId); + if (dispatch == null) return NotFound(); + var now = _timeProvider.GetUtcNow().UtcDateTime; + var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}"; + dispatch.InvoiceStatus = body.InvoiceStatus; + dispatch.PaymentStatus = body.PaymentStatus; + dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim(); + dispatch.CommercialStatusUpdatedAt = now; + dispatch.LastModificationTime = now; + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = Actor(), + FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status", + OldValue = oldValue, + NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}", + Action = "commercial_status_updated", + CreatedAt = now + }); + await _db.SaveChangesAsync(); + return Ok(new + { + dispatch.Id, + dispatch.PONumber, + dispatch.InvoiceNumber, + dispatch.InvoiceStatus, + dispatch.PaymentStatus, + dispatch.CommercialStatusUpdatedAt + }); + } + + [HttpPut("work-orders/{workOrderId:int}/assignment")] + public async Task AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body) + { + var workOrder = await _db.workOrders.FindAsync(workOrderId); + if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" }); + var vendor = await _db.Vendors.FindAsync(body.VendorId); + if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false)) + return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" }); + + var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate; + var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2); + var scheduledDispatches = start.HasValue && end.HasValue + ? await ActiveScheduledDispatches() + .Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId) + .ToListAsync() + : new List(); + var conflicts = start.HasValue && end.HasValue + ? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList() + : new List(); + + var linkedDispatchIds = _db.DispatchWorkOrders + .Where(link => link.WorkOrderId == workOrderId) + .Select(link => link.DispatchId); + var dispatch = await _db.Dispatches + .Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id)) + && !TerminalDispatchStatuses.Contains(item.Status ?? "")) + .OrderByDescending(item => item.CreatedDate) + .FirstOrDefaultAsync(); + var now = _timeProvider.GetUtcNow().UtcDateTime; + var previousVendorId = dispatch?.VendorId; + if (dispatch == null) + { + dispatch = new Dispatch + { + WorkOrderId = workOrderId, + VendorId = vendor.Id, + DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(), + PONumber = await _dispatchDataService.GenerateNextPONumberAsync(), + Status = "Sent", + ScheduledDate = start, + EstimatedArrivalAt = body.EstimatedArrivalAt, + EtaManualOverride = body.EtaManualOverride, + DispatchedAt = now, + CreatedDate = now, + createdby = Actor() + }; + _db.Dispatches.Add(dispatch); + } + else + { + dispatch.VendorId = vendor.Id; + dispatch.ScheduledDate = start; + dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt; + dispatch.EtaManualOverride = body.EtaManualOverride; + dispatch.LastModificationTime = now; + } + + workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart; + workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd; + workOrder.ScheduledDate = start ?? workOrder.ScheduledDate; + workOrder.LastModificationTime = now; + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + UserId = Actor(), + FieldName = "Vendor Assignment", + OldValue = previousVendorId?.ToString(), + NewValue = vendor.Id.ToString(), + Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned", + CreatedAt = now + }); + await _db.SaveChangesAsync(); + + return Ok(new + { + dispatch.Id, + dispatch.VendorId, + VendorName = vendor.CompanyName, + dispatch.EstimatedArrivalAt, + dispatch.EtaManualOverride, + AvailabilityStatus = conflicts.Count > 0 ? "Unavailable" + : vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown", + Conflicts = conflicts.Select(conflict => new + { + conflict.WorkOrderId, + conflict.WorkOrderNumber, + conflict.Start, + conflict.End + }) + }); + } + + [HttpGet("insights")] + public async Task GetInsights( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId) + { + var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + return Ok(new + { + GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime, + Filters = new { from, to, locationId, trade, vendorId }, + MetricDefinitions = MetricDefinitions(), + Data = rows + }); + } + + [HttpGet("insights.csv")] + public async Task ExportInsightsCsv( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId) + { + var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n"); + foreach (var row in rows) + { + csv.AppendLine(string.Join(",", + row.VendorId, + Csv(row.Vendor), + row.CompletedJobs, + row.CancellationRefusalRate.ToString("0.####"), + row.OnTimeArrivalRate.ToString("0.####"), + row.OnTimeCompletionRate.ToString("0.####"), + row.AverageCycleHours?.ToString("0.##") ?? "")); + } + return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv"); + } + + [HttpGet("insights.pdf")] + public async Task ExportInsightsPdf( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId) + { + var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + var lines = new List + { + "Vendor Performance Insights", + $"Generated: {_timeProvider.GetUtcNow():u}", + $"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}", + "Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours." + }; + lines.AddRange(rows.Take(35).Select(row => + $"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h")); + return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf"); + } + + private IQueryable ActiveScheduledDispatches() + { + var direct = _db.Dispatches.AsNoTracking() + .Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "") + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false) + && dispatch.WorkOrderId.HasValue + && (dispatch.ScheduledDate.HasValue + || (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue))) + .Select(dispatch => new ScheduledDispatch + { + Id = dispatch.Id, + VendorId = dispatch.VendorId, + VendorName = dispatch.Vendor!.CompanyName, + WorkOrderId = dispatch.WorkOrderId!.Value, + WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber, + Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value, + End = dispatch.WorkOrder.ScheduledEnd + ?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2) + }); + + var linked = _db.DispatchWorkOrders.AsNoTracking() + .Where(link => link.Dispatch != null + && link.WorkOrder != null + && link.Dispatch.WorkOrderId != link.WorkOrderId + && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "") + && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) + && (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue)) + .Select(link => new ScheduledDispatch + { + Id = link.DispatchId, + VendorId = link.Dispatch!.VendorId, + VendorName = link.Dispatch.Vendor!.CompanyName, + WorkOrderId = link.WorkOrderId, + WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber, + Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value, + End = link.WorkOrder.ScheduledEnd + ?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2) + }); + + return direct.Concat(linked); + } + + private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List dispatches) + { + for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++) + { + for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++) + { + var left = dispatches[leftIndex]; + var right = dispatches[rightIndex]; + if (left.VendorId == right.VendorId + && left.WorkOrderId != right.WorkOrderId + && WindowsOverlap(left.Start, left.End, right.Start, right.End)) + yield return (left, right); + } + } + } + + private async Task> BuildInsightRows( + DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId) + { + var query = _db.Dispatches.AsNoTracking() + .Include(dispatch => dispatch.Vendor) + .Include(dispatch => dispatch.WorkOrder) + .Where(dispatch => dispatch.Vendor != null); + if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from); + if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to); + if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId); + if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade); + if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId); + + var dispatches = await query.ToListAsync(); + return dispatches.GroupBy(dispatch => dispatch.VendorId) + .Select(group => + { + var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList(); + var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused"); + var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList(); + var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList(); + var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList(); + return new VendorInsightRow + { + VendorId = group.Key, + Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}", + CompletedJobs = completed.Count, + CancellationRefusalRate = Rate(canceled, group.Count()), + OnTimeArrivalRate = Rate( + acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate), + acknowledgedWithSchedule.Count), + OnTimeCompletionRate = Rate( + completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate), + completedWithDue.Count), + AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch => + (dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours) + }; + }) + .OrderBy(row => row.Vendor) + .ToList(); + } + + private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.Identity?.Name + ?? "unknown"; + + private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd) + => leftStart < rightEnd && rightStart < leftEnd; + + private static decimal Rate(int numerator, int denominator) + => denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4); + + private static string Csv(string value) + { + var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0]) + ? $"'{value}" + : value; + return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\""; + } + + private static object MetricDefinitions() => new + { + CompletedJobs = "Dispatches with Completed or Verified status.", + CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.", + OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.", + OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.", + AverageCycleHours = "Average elapsed hours from dispatch to completion." + }; + + private static byte[] SimplePdf(IEnumerable lines) + { + var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)")); + var content = "BT /F1 10 Tf 40 760 Td 13 TL " + + string.Join(" T* ", escaped.Select(line => $"({line}) Tj")) + + " ET"; + var objects = new[] + { + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + $"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" + }; + var builder = new StringBuilder("%PDF-1.4\n"); + var offsets = new List { 0 }; + for (var index = 0; index < objects.Length; index++) + { + offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString())); + builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n"); + } + var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString()); + builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n"); + foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n"); + builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF"); + return Encoding.ASCII.GetBytes(builder.ToString()); + } + + public sealed class AvailabilityRequest + { + public string? Status { get; set; } + } + + public sealed class SitePreferenceRequest + { + public int VendorId { get; set; } + public string? Trade { get; set; } + public int? SortOrder { get; set; } + } + + public sealed class DocumentReviewRequest + { + public string ReviewStatus { get; set; } = ""; + public string? RejectionReason { get; set; } + } + + public sealed class CommercialStatusRequest + { + public string InvoiceStatus { get; set; } = "Not Submitted"; + public string PaymentStatus { get; set; } = "Unavailable"; + public string? InvoiceNumber { get; set; } + } + + public sealed class VendorAssignmentRequest + { + public int VendorId { get; set; } + public DateTime? ScheduledStart { get; set; } + public DateTime? ScheduledEnd { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + } + + private sealed class ScheduledDispatch + { + public int Id { get; set; } + public int VendorId { get; set; } + public string? VendorName { get; set; } + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } + + private sealed class VendorInsightRow + { + public int VendorId { get; set; } + public string Vendor { get; set; } = ""; + public int CompletedJobs { get; set; } + public decimal CancellationRefusalRate { get; set; } + public decimal OnTimeArrivalRate { get; set; } + public decimal OnTimeCompletionRate { get; set; } + public double? AverageCycleHours { get; set; } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 8841861..eba515f 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -20,13 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers private readonly VendorPortalTokenService _tokens; private readonly IConfiguration _config; private readonly SendMessage _sendMessage; + private readonly IWebHostEnvironment _environment; - public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage) + public VendorPortalController( + ApplicationDbContext db, + VendorPortalTokenService tokens, + IConfiguration config, + SendMessage sendMessage, + IWebHostEnvironment environment) { _db = db; _tokens = tokens; _config = config; _sendMessage = sendMessage; + _environment = environment; } [HttpGet("session")] @@ -71,6 +78,9 @@ namespace Api.SeaHavenIndustries.Controllers d.Id, d.DispatchNumber, d.PONumber, + d.InvoiceNumber, + d.InvoiceStatus, + d.PaymentStatus, d.Status, d.NTEAmount, d.ScheduledDate, @@ -108,6 +118,27 @@ namespace Api.SeaHavenIndustries.Controllers .Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt }) .ToListAsync(); + var documents = await _db.VendorCompletionDocuments + .Where(document => document.DispatchId == id && document.VendorId == vendor.Id) + .OrderByDescending(document => document.CreatedDate) + .Select(document => new + { + document.Id, + document.OriginalFileName, + document.ContentType, + document.SizeBytes, + document.ScanStatus, + document.ReviewStatus, + document.RejectionReason, + document.Version, + document.ReplacesDocumentId, + document.CreatedDate, + document.ScannedAt, + document.ReviewedAt, + CanDownload = document.ScanStatus == "Passed" + }) + .ToListAsync(); + var rawUplifts = await (from u in _db.DispatchUpliftRequests where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false) join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs @@ -210,6 +241,10 @@ namespace Api.SeaHavenIndustries.Controllers dispatch.Id, dispatch.DispatchNumber, dispatch.PONumber, + dispatch.InvoiceNumber, + dispatch.InvoiceStatus, + dispatch.PaymentStatus, + dispatch.CommercialStatusUpdatedAt, dispatch.Status, dispatch.NTEAmount, dispatch.Description, @@ -239,6 +274,7 @@ namespace Api.SeaHavenIndustries.Controllers }, Checklist = checklist, Signoffs = signoffs, + Documents = documents, Comments = comments, UpliftRequests = upliftRequests } @@ -589,6 +625,133 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); } + [HttpPost("dispatches/{id:int}/documents")] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task UploadCompletionDocument( + int id, + [FromForm] IFormFile file, + [FromForm] int? replacesDocumentId = null) + { + var vendor = await ResolveVendorAsync(); + if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); + if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" }); + if (file.Length > 10 * 1024 * 1024) + return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" }); + + var allowed = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["application/pdf"] = new[] { ".pdf" }, + ["image/jpeg"] = new[] { ".jpg", ".jpeg" }, + ["image/png"] = new[] { ".png" } + }; + var extension = Path.GetExtension(file.FileName); + if (!allowed.TryGetValue(file.ContentType, out var extensions) + || !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" }); + + await using var signatureStream = file.OpenReadStream(); + if (!await HasExpectedSignature(signatureStream, file.ContentType)) + return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" }); + + var workOrderId = dispatch.WorkOrderId + ?? await _db.DispatchWorkOrders + .Where(link => link.DispatchId == dispatch.Id) + .OrderBy(link => link.Id) + .Select(link => link.WorkOrderId) + .FirstOrDefaultAsync(); + if (workOrderId == 0) + return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" }); + + VendorCompletionDocument? replaced = null; + if (replacesDocumentId.HasValue) + { + replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document => + document.Id == replacesDocumentId.Value + && document.DispatchId == id + && document.VendorId == vendor.Id); + if (replaced == null) + return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" }); + } + + var now = DateTime.UtcNow; + var document = new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = id, + WorkOrderId = workOrderId, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}", + ContentType = file.ContentType, + SizeBytes = file.Length, + ScanStatus = "Pending", + ReviewStatus = "Processing", + Version = (replaced?.Version ?? 0) + 1, + ReplacesDocumentId = replaced?.Id, + CreatedDate = now, + createdby = $"vendor:{vendor.Id}" + }; + var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using (var target = System.IO.File.Create(path)) + { + await file.CopyToAsync(target); + } + + try + { + _db.VendorCompletionDocuments.Add(document); + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = document.WorkOrderId, + FieldName = "Completion Document", + OldValue = replaced?.OriginalFileName, + NewValue = document.OriginalFileName, + Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced", + CreatedAt = now + }); + await _db.SaveChangesAsync(); + } + catch + { + System.IO.File.Delete(path); + throw; + } + + return Ok(new DataResponse + { + Status = "Success", + Data = new + { + document.Id, + document.OriginalFileName, + document.SizeBytes, + document.ScanStatus, + document.ReviewStatus, + document.Version, + document.ReplacesDocumentId + } + }); + } + + [HttpGet("dispatches/{id:int}/documents/{documentId:int}")] + public async Task DownloadCompletionDocument(int id, int documentId) + { + var vendor = await ResolveVendorAsync(); + if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item => + item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id); + if (document == null) return NotFound(); + if (document.ScanStatus != "Passed") + return StatusCode(StatusCodes.Status423Locked, + new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" }); + var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); + if (!System.IO.File.Exists(path)) return NotFound(); + return PhysicalFile(path, document.ContentType, document.OriginalFileName); + } + private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor) { try @@ -636,6 +799,19 @@ namespace Api.SeaHavenIndustries.Controllers } } + private static async Task HasExpectedSignature(Stream stream, string contentType) + { + var bytes = new byte[8]; + var count = await stream.ReadAsync(bytes); + if (contentType == "application/pdf") + return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D; + if (contentType == "image/jpeg") + return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + if (contentType == "image/png") + return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); + return false; + } + private async Task ResolveVendorAsync() { if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9c3b181..1f86f21 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -494,6 +494,8 @@ namespace Api.SeaHavenIndustries.Controllers d.Description, d.Status, d.ScheduledDate, + d.EstimatedArrivalAt, + d.EtaManualOverride, d.CompletedDate, d.DispatchedAt, d.EmailSent, @@ -863,7 +865,10 @@ namespace Api.SeaHavenIndustries.Controllers foreach (var vendorId in model.VendorIds) { var vendor = await _vendorDataService.GetByIdAsync(vendorId); - if (vendor == null) continue; + if (vendor == null) + return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} was not found" }); + if (!vendor.IsActive || (vendor.IsDeleted ?? false)) + return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} is inactive and cannot receive a new assignment" }); var poNumber = $"VPO-{nextPO:D5}"; nextPO++; @@ -882,6 +887,8 @@ namespace Api.SeaHavenIndustries.Controllers Description = model.Description, Status = "Sent", ScheduledDate = model.ScheduledDate, + EstimatedArrivalAt = model.EstimatedArrivalAt, + EtaManualOverride = model.EtaManualOverride, DispatchedAt = DateTime.UtcNow, ReplyToAddress = replyTo, DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() @@ -961,6 +968,19 @@ namespace Api.SeaHavenIndustries.Controllers Action = AuditAction.Dispatch.ToString(), CreatedAt = DateTime.UtcNow }); + if (model.EstimatedArrivalAt.HasValue) + { + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = wo.Id, + UserId = userId, + FieldName = "Vendor ETA", + OldValue = "", + NewValue = model.EstimatedArrivalAt.Value.ToUniversalTime().ToString("O"), + Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_set", + CreatedAt = DateTime.UtcNow + }); + } } dispatches.Add(new { savedDispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, savedDispatch.Status, workOrderCount = workOrders.Count }); @@ -987,6 +1007,8 @@ namespace Api.SeaHavenIndustries.Controllers d.Description, d.Status, d.ScheduledDate, + d.EstimatedArrivalAt, + d.EtaManualOverride, d.CompletedDate, d.DispatchedAt, d.EmailSent, @@ -1013,6 +1035,8 @@ namespace Api.SeaHavenIndustries.Controllers x.Description, x.Status, x.ScheduledDate, + x.EstimatedArrivalAt, + x.EtaManualOverride, x.CompletedDate, x.DispatchedAt, x.EmailSent, @@ -1101,6 +1125,21 @@ namespace Api.SeaHavenIndustries.Controllers dispatch.Status = model.Status; dispatch.NTEAmount = model.NTEAmount; dispatch.ScheduledDate = model.ScheduledDate; + if (dispatch.EstimatedArrivalAt != model.EstimatedArrivalAt) + { + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = userId, + FieldName = "Vendor ETA", + OldValue = dispatch.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "", + NewValue = model.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "", + Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_change", + CreatedAt = DateTime.UtcNow + }); + } + dispatch.EstimatedArrivalAt = model.EstimatedArrivalAt; + dispatch.EtaManualOverride = model.EtaManualOverride; dispatch.CompletedDate = model.CompletedDate; dispatch.Description = model.Description; diff --git a/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs b/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs index 4601436..49f17f0 100644 --- a/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs @@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs public List VendorIds { get; set; } = new(); public decimal? NTEAmount { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } public string? Description { get; set; } public int? TaskListTemplateId { get; set; } public List? CustomChecklistItems { get; set; } diff --git a/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs b/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs index 31770e1..40052f1 100644 --- a/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs @@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs public string? Status { get; set; } public decimal? NTEAmount { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } public DateTime? CompletedDate { get; set; } public string? Description { get; set; } } diff --git a/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs b/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs new file mode 100644 index 0000000..7280c9f --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs @@ -0,0 +1,70 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using System.Text; + +namespace Api.SeaHavenIndustries.Helper +{ + public enum DocumentScanResult + { + Passed, + Infected, + Unavailable + } + + public interface IDocumentScanner + { + Task ScanAsync(string path, CancellationToken cancellationToken); + } + + public class ClamAvDocumentScanner : IDocumentScanner + { + private readonly IConfiguration _configuration; + + public ClamAvDocumentScanner(IConfiguration configuration) + { + _configuration = configuration; + } + + public async Task ScanAsync(string path, CancellationToken cancellationToken) + { + var host = _configuration["ClamAV:Host"]; + var port = _configuration.GetValue("ClamAV:Port") ?? 3310; + if (string.IsNullOrWhiteSpace(host) || !File.Exists(path)) + return DocumentScanResult.Unavailable; + + try + { + using var client = new TcpClient(); + await client.ConnectAsync(host, port, cancellationToken); + await using var network = client.GetStream(); + await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken); + await using var file = File.OpenRead(path); + var buffer = new byte[64 * 1024]; + while (true) + { + var count = await file.ReadAsync(buffer, cancellationToken); + if (count == 0) break; + var length = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, count); + await network.WriteAsync(length, cancellationToken); + await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken); + } + await network.WriteAsync(new byte[4], cancellationToken); + await network.FlushAsync(cancellationToken); + + var responseBuffer = new byte[1024]; + var responseLength = await network.ReadAsync(responseBuffer, cancellationToken); + var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength); + if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase)) + return DocumentScanResult.Infected; + return response.Contains("OK", StringComparison.OrdinalIgnoreCase) + ? DocumentScanResult.Passed + : DocumentScanResult.Unavailable; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + return DocumentScanResult.Unavailable; + } + } + } +} diff --git a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs new file mode 100644 index 0000000..0f4adcd --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs @@ -0,0 +1,75 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; + +namespace Api.SeaHavenIndustries.Helper +{ + public class VendorDocumentScanWorker : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + public VendorDocumentScanWorker( + IServiceScopeFactory scopeFactory, + IWebHostEnvironment environment, + ILogger logger) + { + _scopeFactory = scopeFactory; + _environment = environment; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await ScanPendingAsync(stoppingToken); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } + + private async Task ScanPendingAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var scanner = scope.ServiceProvider.GetRequiredService(); + var pending = await db.VendorCompletionDocuments + .Where(document => document.ScanStatus == "Pending") + .OrderBy(document => document.CreatedDate) + .Take(20) + .ToListAsync(cancellationToken); + + foreach (var document in pending) + { + var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); + var result = await scanner.ScanAsync(path, cancellationToken); + if (result == DocumentScanResult.Unavailable) + { + _logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id); + continue; + } + + document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected"; + document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected"; + document.RejectionReason = result == DocumentScanResult.Infected + ? "The upload failed malware scanning." + : document.RejectionReason; + document.ScannedAt = DateTime.UtcNow; + document.LastModificationTime = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + if (result == DocumentScanResult.Infected && File.Exists(path)) + File.Delete(path); + } + } + } + + public static class VendorDocumentStorage + { + public static string Root(string contentRoot) => + Path.Combine(contentRoot, "App_Data", "vendor-documents"); + + public static string ResolvePath(string contentRoot, VendorCompletionDocument document) => + Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName); + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..17022c8 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -39,6 +39,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); builder.Services.AddSingleton(sp => { var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance(); diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index c7c85c6..9511955 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -61,6 +61,16 @@ namespace Data.SeaHavenIndustries .WithMany(c => c.Vendors) .HasForeignKey(v => v.CompanyId) .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(p => new { p.LocationId, p.VendorId, p.Trade }) + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + builder.Entity() + .HasIndex(p => new { p.LocationId, p.VendorId }) + .IsUnique() + .HasFilter("[Trade] IS NULL"); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -81,6 +91,8 @@ namespace Data.SeaHavenIndustries public DbSet VendorCompanies { get; set; } public DbSet VendorAuditLogs { get; set; } public DbSet VendorAccessTokens { get; set; } + public DbSet VendorCompletionDocuments { get; set; } + public DbSet SitePreferredVendors { get; set; } public DbSet Dispatches { get; set; } public DbSet DispatchWorkOrders { get; set; } public DbSet DispatchChecklistItems { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs new file mode 100644 index 0000000..9dc7d67 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs @@ -0,0 +1,3441 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260723220614_AddVendorRoadmapOperations")] + partial class AddVendorRoadmapOperations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs new file mode 100644 index 0000000..bea27f8 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs @@ -0,0 +1,230 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class AddVendorRoadmapOperations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvailabilityStatus", + table: "Vendors", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "AvailabilityUpdatedAt", + table: "Vendors", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "CommercialStatusUpdatedAt", + table: "Dispatches", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "EstimatedArrivalAt", + table: "Dispatches", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "EtaManualOverride", + table: "Dispatches", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InvoiceNumber", + table: "Dispatches", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "InvoiceStatus", + table: "Dispatches", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "PaymentStatus", + table: "Dispatches", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "SitePreferredVendors", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + LocationId = table.Column(type: "int", nullable: false), + VendorId = table.Column(type: "int", nullable: false), + Trade = table.Column(type: "nvarchar(450)", nullable: true), + SortOrder = table.Column(type: "int", nullable: false), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SitePreferredVendors", x => x.Id); + table.ForeignKey( + name: "FK_SitePreferredVendors_Locations_LocationId", + column: x => x.LocationId, + principalTable: "Locations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SitePreferredVendors_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "VendorCompletionDocuments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + VendorId = table.Column(type: "int", nullable: false), + DispatchId = table.Column(type: "int", nullable: false), + WorkOrderId = table.Column(type: "int", nullable: false), + OriginalFileName = table.Column(type: "nvarchar(max)", nullable: false), + StoredFileName = table.Column(type: "nvarchar(max)", nullable: false), + ContentType = table.Column(type: "nvarchar(max)", nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + ScanStatus = table.Column(type: "nvarchar(max)", nullable: false), + ReviewStatus = table.Column(type: "nvarchar(max)", nullable: false), + RejectionReason = table.Column(type: "nvarchar(max)", nullable: true), + Version = table.Column(type: "int", nullable: false), + ReplacesDocumentId = table.Column(type: "int", nullable: true), + ScannedAt = table.Column(type: "datetime2", nullable: true), + ReviewedAt = table.Column(type: "datetime2", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_Dispatches_DispatchId", + column: x => x.DispatchId, + principalTable: "Dispatches", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "workOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_LocationId_VendorId", + table: "SitePreferredVendors", + columns: new[] { "LocationId", "VendorId" }, + unique: true, + filter: "[Trade] IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_LocationId_VendorId_Trade", + table: "SitePreferredVendors", + columns: new[] { "LocationId", "VendorId", "Trade" }, + unique: true, + filter: "[Trade] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_VendorId", + table: "SitePreferredVendors", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_DispatchId", + table: "VendorCompletionDocuments", + column: "DispatchId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_VendorId", + table: "VendorCompletionDocuments", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_WorkOrderId", + table: "VendorCompletionDocuments", + column: "WorkOrderId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SitePreferredVendors"); + + migrationBuilder.DropTable( + name: "VendorCompletionDocuments"); + + migrationBuilder.DropColumn( + name: "AvailabilityStatus", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "AvailabilityUpdatedAt", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "CommercialStatusUpdatedAt", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "EstimatedArrivalAt", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "EtaManualOverride", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "InvoiceNumber", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "InvoiceStatus", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "PaymentStatus", + table: "Dispatches"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 09b4ef3..33dd3a9 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -631,6 +631,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("AcknowledgedAt") .HasColumnType("datetime2"); + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + b.Property("CompletedDate") .HasColumnType("datetime2"); @@ -655,6 +658,19 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("EmailSent") .HasColumnType("bit"); + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("IsDeleted") .HasColumnType("bit"); @@ -670,6 +686,10 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("PONumber") .HasColumnType("nvarchar(max)"); + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("ReplyToAddress") .HasColumnType("nvarchar(max)"); @@ -1147,6 +1167,86 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("EmployeePhones"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => { b.Property("Id") @@ -1526,6 +1626,65 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("Regions"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => { b.Property("Id") @@ -1727,6 +1886,13 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Address") .HasColumnType("nvarchar(max)"); + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + b.Property("City") .HasColumnType("nvarchar(max)"); @@ -1958,6 +2124,93 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("VendorCompanies"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.Property("Id") @@ -2796,6 +3049,25 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Quotes"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => { b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") @@ -2870,6 +3142,33 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Vendor"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") @@ -3060,6 +3359,8 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Comments"); + b.Navigation("CompletionDocuments"); + b.Navigation("DispatchWorkOrders"); b.Navigation("Signoffs"); @@ -3106,6 +3407,8 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("AuditLogs"); b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => diff --git a/Data.SeaHavenIndustries/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs index 831cc17..4e7b927 100644 --- a/Data.SeaHavenIndustries/Models/Dispatch.cs +++ b/Data.SeaHavenIndustries/Models/Dispatch.cs @@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries public string? Description { get; set; } public string? Status { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + public string InvoiceStatus { get; set; } = "Not Submitted"; + public string PaymentStatus { get; set; } = "Unavailable"; + public string? InvoiceNumber { get; set; } + public DateTime? CommercialStatusUpdatedAt { get; set; } public DateTime? CompletedDate { get; set; } public DateTime? DispatchedAt { get; set; } public DateTime? AcknowledgedAt { get; set; } @@ -35,5 +41,6 @@ namespace Data.SeaHavenIndustries public ICollection? DispatchWorkOrders { get; set; } public ICollection? ChecklistItems { get; set; } public ICollection? Signoffs { get; set; } + public ICollection? CompletionDocuments { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs b/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs new file mode 100644 index 0000000..7dc34eb --- /dev/null +++ b/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class SitePreferredVendor : FullAuditEntity + { + public int LocationId { get; set; } + [ForeignKey(nameof(LocationId))] + public Locations? Location { get; set; } + + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public Vendor? Vendor { get; set; } + + public string? Trade { get; set; } + public int SortOrder { get; set; } + public string? UpdatedBy { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index 9f8071a..62ba763 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -18,6 +18,8 @@ namespace Data.SeaHavenIndustries public string? Notes { get; set; } public bool IsActive { get; set; } = true; public string? PreferredContact { get; set; } + public string AvailabilityStatus { get; set; } = "Unknown"; + public DateTime? AvailabilityUpdatedAt { get; set; } public int? CompanyId { get; set; } [ForeignKey(nameof(CompanyId))] @@ -27,5 +29,6 @@ namespace Data.SeaHavenIndustries public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } public ICollection? AuditLogs { get; set; } + public ICollection? SitePreferences { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs b/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs new file mode 100644 index 0000000..2058e00 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorCompletionDocument : FullAuditEntity + { + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public Vendor? Vendor { get; set; } + + public int DispatchId { get; set; } + [ForeignKey(nameof(DispatchId))] + public Dispatch? Dispatch { get; set; } + + public int WorkOrderId { get; set; } + [ForeignKey(nameof(WorkOrderId))] + public WorkOrder? WorkOrder { get; set; } + + public string OriginalFileName { get; set; } = ""; + public string StoredFileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public long SizeBytes { get; set; } + public string ScanStatus { get; set; } = "Pending"; + public string ReviewStatus { get; set; } = "Processing"; + public string? RejectionReason { get; set; } + public int Version { get; set; } = 1; + public int? ReplacesDocumentId { get; set; } + public DateTime? ScannedAt { get; set; } + public DateTime? ReviewedAt { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 18e51bf..81c6ad8 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -27,6 +27,7 @@ namespace Data.SeaHavenIndustries public string? LegacyStatus { get; set; } public WorkOrderType? WorkOrderType { get; set; } /// SHOC board "Service" column (PM type label). Not Trade/Problem. + [MaxLength(128)] public string? Service { get; set; } public int? PrimaryDispatchId { get; set; } [ForeignKey(nameof(PrimaryDispatchId))] From 11fed06f036c30172461fdc60b6d0fd1ffdef638 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 8 Jul 2026 14:58:59 -0300 Subject: [PATCH 17/30] feat(work-orders): add Phase 4 board search and advanced search API Harden contextual search on the weekly board and expose paginated cross-week GET /board/search with date presets, FE filter params, SQL indexes, and unit tests. --- .../Controllers/WorkOrderController.cs | 13 +- .../20260624200000_Phase4_SearchIndexes.cs | 48 ++++ .../Helpers/WorkOrderBoardQueryFilters.cs | 19 -- .../Helpers/WorkOrderBoardSearchFilter.cs | 47 ++++ .../WorkOrderAdvancedSearchDataService.cs | 64 +++++ .../WorkOrderBoardDataService.cs | 7 +- .../IWorkOrderAdvancedSearchDataService.cs | 9 + .../WorkOrderAdvancedSearchModels.cs | 27 ++ .../DTOs/WorkOrderBoardRequestDTOs.cs | 22 ++ .../WorkOrderSearchDateRangeResolver.cs | 95 +++++++ .../WorkOrderAdvancedSearchService.cs | 63 +++++ .../IWorkOrderAdvancedSearchService.cs | 11 + .../WorkOrderBoardSearchTests.cs | 240 ++++++++++++++++++ scripts/load-test/README.md | 40 +++ scripts/load-test/work-order-search.k6.js | 60 +++++ 15 files changed, 742 insertions(+), 23 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs create mode 100644 scripts/load-test/README.md create mode 100644 scripts/load-test/work-order-search.k6.js diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index cdf1d24..6d597a3 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -49,6 +49,7 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderBoardUpdateService _boardUpdateService; private readonly IWorkOrderBoardCreateService _boardCreateService; private readonly IWorkOrderBoardCancelService _boardCancelService; + private readonly IWorkOrderAdvancedSearchService _advancedSearchService; public WorkOrderController( UserManager userManager, @@ -69,7 +70,8 @@ namespace Api.SeaHavenIndustries.Controllers IWorkOrderBoardService workOrderBoardService, IWorkOrderBoardUpdateService boardUpdateService, IWorkOrderBoardCreateService boardCreateService, - IWorkOrderBoardCancelService boardCancelService) + IWorkOrderBoardCancelService boardCancelService, + IWorkOrderAdvancedSearchService advancedSearchService) { _userManager = userManager; _workOrderService = workOrderService; @@ -90,6 +92,7 @@ namespace Api.SeaHavenIndustries.Controllers _boardUpdateService = boardUpdateService; _boardCreateService = boardCreateService; _boardCancelService = boardCancelService; + _advancedSearchService = advancedSearchService; } [HttpGet("board")] @@ -121,6 +124,14 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(result); } + [HttpGet("board/search")] + public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _advancedSearchService.SearchAsync(query, userId); + return Ok(result); + } + [HttpGet("lookups/dispatchers")] public async Task GetDispatcherLookups() { diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs new file mode 100644 index 0000000..427b6cf --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase4_SearchIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_workOrders_SiteCode", + table: "workOrders", + column: "SiteCode", + filter: "[istemplate] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_InternalWONumber", + table: "workOrders", + column: "InternalWONumber", + filter: "[istemplate] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_LifecycleStatus_ScheduledDate", + table: "workOrders", + columns: new[] { "LifecycleStatus", "ScheduledDate" }, + filter: "[istemplate] = 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_workOrders_SiteCode", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_InternalWONumber", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_LifecycleStatus_ScheduledDate", + table: "workOrders"); + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index c57fc5f..4daa878 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -138,24 +138,5 @@ namespace SeaHaven.DataServices.Helpers } #endregion - - public static IQueryable ApplySearchFilter(IQueryable query, string? search) - { - if (string.IsNullOrWhiteSpace(search)) - return query; - - var s = search.Trim().ToLower(); - return query.Where(w => - (w.SiteCode ?? "").ToLower().Contains(s) - || (w.InternalWONumber ?? "").ToLower().Contains(s) - || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) - || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) - || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) - || (w.Service ?? "").ToLower().Contains(s) - || (w.Trade ?? "").ToLower().Contains(s) - || (w.Problem ?? "").ToLower().Contains(s) - || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) - || (w.Status ?? "").ToLower().Contains(s)); - } } } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs new file mode 100644 index 0000000..eac12ba --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -0,0 +1,47 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardSearchFilter + { + public static string? NormalizeSearch(string? search) + { + if (string.IsNullOrWhiteSpace(search)) + return null; + + var trimmed = search.Trim(); + return trimmed.Length < 2 ? null : trimmed; + } + + public static IQueryable Apply(IQueryable query, string? search) + { + var normalized = NormalizeSearch(search); + if (normalized == null) + return query; + + var s = normalized.ToLower(); + return query.Where(w => + (w.SiteCode ?? "").ToLower().Contains(s) + || (w.InternalWONumber ?? "").ToLower().Contains(s) + || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) + || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) + || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) + || (w.Trade ?? "").ToLower().Contains(s) + || (w.Problem ?? "").ToLower().Contains(s) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s)) + || (w.Status ?? "").ToLower().Contains(s) + || (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.EnRoute && ("en route".Contains(s) || "enroute".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.OnSite && ("on site".Contains(s) || "onsite".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.InProgress && ("in progress".Contains(s) || "inprogress".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s)))); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs new file mode 100644 index 0000000..556fe4f --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,64 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderAdvancedSearchDataService : IWorkOrderAdvancedSearchDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderAdvancedSearchDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task SearchAsync(WorkOrderAdvancedSearchQuery query) + { + var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); + baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); + baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types); + baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( + baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); + baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses); + baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes); + baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds); + baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses); + + var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); + if (normalizedSearch != null) + baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch); + + var totalCount = await baseQuery.CountAsync(); + + var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); + var paged = sorted + .Skip((query.Page - 1) * query.PageSize) + .Take(query.PageSize); + + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false); + return new WorkOrderAdvancedSearchResult(rows, totalCount); + } + + private static IQueryable ApplySort(IQueryable query, string sortBy, string sortDir) + { + var descending = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase); + var field = (sortBy ?? "scheduledDate").Trim().ToLowerInvariant(); + + return field switch + { + "wonumber" or "woNumber" => descending + ? query.OrderByDescending(w => w.InternalWONumber) + : query.OrderBy(w => w.InternalWONumber), + "duedate" or "dueDate" => descending + ? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber) + : query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber), + _ => descending + ? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber) + : query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber) + }; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 2389fdc..17e21b0 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -37,10 +37,11 @@ namespace SeaHaven.DataServices.Implementation var scheduledTotal = await scheduledBase.CountAsync(); - if (!string.IsNullOrWhiteSpace(query.Search)) + var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); + if (normalizedSearch != null) { - scheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(scheduledBase, query.Search); - unscheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(unscheduledBase, query.Search); + scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch); + unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch); } var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync( diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs new file mode 100644 index 0000000..f758550 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,9 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderAdvancedSearchDataService + { + Task SearchAsync(WorkOrderAdvancedSearchQuery query); + } +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs new file mode 100644 index 0000000..0cede89 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderAdvancedSearchQuery( + string? Search, + DateOnly DateFrom, + DateOnly DateTo, + IReadOnlyList? Sites, + IReadOnlyList? Types, + IReadOnlyList? Dispatchers, + IReadOnlyList? Statuses, + IReadOnlyList? PmTypes, + IReadOnlyList? VendorIds, + IReadOnlyList? DocStatuses, + bool MyWorkOrders, + string? CurrentUserId, + int Page, + int PageSize, + string SortBy, + string SortDir); + + public record WorkOrderAdvancedSearchResult( + IReadOnlyList Rows, + int TotalCount); +} diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index d3ac591..31fae59 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Helpers; namespace SeaHaven.Services.DTOs { @@ -23,4 +24,25 @@ namespace SeaHaven.Services.DTOs public int? PocContactId { get; set; } public string? PocNotes { get; set; } } + + /// Advanced search / filter query for the board. + public class WorkOrderAdvancedSearchQueryDto + { + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public string? Search { get; set; } + public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } + public List? Sites { get; set; } + public List? Types { get; set; } + public List? Dispatchers { get; set; } + public List? Statuses { get; set; } + public List? PmTypes { get; set; } + public List? VendorIds { get; set; } + public List? DocStatuses { get; set; } + public bool MyWorkOrders { get; set; } + public string? SortBy { get; set; } + public string? SortDir { get; set; } + } } diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs new file mode 100644 index 0000000..d41df2f --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -0,0 +1,95 @@ +namespace SeaHaven.Services.Helpers +{ + public enum WorkOrderAdvancedSearchDatePreset + { + ThisWeek, + LastWeek, + ThisMonth, + Last3Months, + NextWeek, + NextMonth, + Custom + } + + public record WorkOrderSearchDateRange(DateOnly From, DateOnly To); + + public static class WorkOrderSearchDateRangeResolver + { + public static WorkOrderSearchDateRange Resolve( + WorkOrderAdvancedSearchDatePreset? preset, + DateOnly? dateFrom, + DateOnly? dateTo, + DateOnly? referenceDate = null) + { + var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + var resolvedPreset = preset ?? WorkOrderAdvancedSearchDatePreset.ThisWeek; + + if (resolvedPreset == WorkOrderAdvancedSearchDatePreset.Custom) + { + if (!dateFrom.HasValue || !dateTo.HasValue) + throw new ArgumentException("dateFrom and dateTo are required when datePreset is custom."); + + if (dateTo.Value < dateFrom.Value) + throw new ArgumentException("dateTo must be on or after dateFrom."); + + return new WorkOrderSearchDateRange(dateFrom.Value, dateTo.Value); + } + + return resolvedPreset switch + { + WorkOrderAdvancedSearchDatePreset.ThisWeek => ThisIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.LastWeek => LastIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.ThisMonth => ThisMonth(today), + WorkOrderAdvancedSearchDatePreset.Last3Months => Last3Months(today), + WorkOrderAdvancedSearchDatePreset.NextWeek => NextIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.NextMonth => NextMonth(today), + _ => ThisIsoWeek(today) + }; + } + + public static DateOnly GetIsoWeekStart(DateOnly date) + { + var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; + return date.AddDays(-diff); + } + + private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(-7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange ThisMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange NextMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1).AddMonths(1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange Last3Months(DateOnly today) + { + var end = today; + var start = today.AddMonths(-3); + return new WorkOrderSearchDateRange(start, end); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs new file mode 100644 index 0000000..285ec9e --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -0,0 +1,63 @@ +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService + { + private const int DefaultPageSize = 50; + private const int MaxPageSize = 100; + + private readonly IWorkOrderAdvancedSearchDataService _searchDataService; + + public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService) + { + _searchDataService = searchDataService; + } + + public async Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId) + { + var page = query.Page < 1 ? 1 : query.Page; + var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); + + var dateRange = WorkOrderSearchDateRangeResolver.Resolve( + query.DatePreset, + query.DateFrom, + query.DateTo); + + var dataQuery = new WorkOrderAdvancedSearchQuery( + WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), + dateRange.From, + dateRange.To, + query.Sites, + query.Types, + query.Dispatchers, + query.Statuses, + query.PmTypes, + query.VendorIds, + query.DocStatuses, + query.MyWorkOrders, + currentUserId, + page, + pageSize, + query.SortBy ?? "scheduledDate", + query.SortDir ?? "asc"); + + var result = await _searchDataService.SearchAsync(dataQuery); + var utcNow = DateTime.UtcNow; + + return new PagedResult + { + Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(), + TotalCount = result.TotalCount, + Page = page, + PageSize = pageSize + }; + } + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs new file mode 100644 index 0000000..8da8835 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderAdvancedSearchService + { + Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs new file mode 100644 index 0000000..290b63d --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -0,0 +1,240 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardSearchFilterTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Theory] + [InlineData("BK5", 1)] + [InlineData("10000000001", 1)] + [InlineData("HVAC Tech", 1)] + [InlineData("Acme HVAC", 1)] + [InlineData("sched", 1)] + public async Task Apply_MatchesExpectedField(string search, int expectedId) + { + await using var context = CreateContext(); + await SeedSearchFixtures(context); + + var ids = await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsQueryable(), search) + .Select(w => w.Id) + .ToListAsync(); + + Assert.Contains(expectedId, ids); + } + + [Fact] + public void NormalizeSearch_IgnoresSingleCharacter() + { + Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch("a")); + Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch(" ")); + Assert.Equal("ab", WorkOrderBoardSearchFilter.NormalizeSearch(" ab ")); + } + + [Fact] + public async Task Apply_LifecycleLabel_MatchesScheduled() + { + await using var context = CreateContext(); + await SeedSearchFixtures(context); + + var ids = await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsQueryable(), "scheduled") + .Select(w => w.Id) + .ToListAsync(); + + Assert.Single(ids); + Assert.Equal(1, ids[0]); + } + + private static async Task SeedSearchFixtures(ApplicationDbContext context) + { + context.Vendors.Add(new Vendor + { + Id = 1, + CompanyName = "Acme HVAC", + ContactName = "HVAC Tech", + Phone = "555-0100" + }); + context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1 }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + SiteCode = "BK5", + InternalWONumber = "10000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10 + }); + await context.SaveChangesAsync(); + } +} + +public class WorkOrderBoardSearchCountsTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardRows_SearchReducesScheduledAndKeepsTotal() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + SiteCode = "BK5", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new SeaHaven.DataServices.Interfaces.WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + "BK5", + null)); + + Assert.Equal(2, result.ScheduledTotalBeforeSearch); + Assert.Single(result.ScheduledRows); + Assert.Equal("BK5", result.ScheduledRows[0].SiteCode); + Assert.Single(result.UnscheduledRows); + Assert.Equal("BK5", result.UnscheduledRows[0].SiteCode); + } +} + +public class WorkOrderSearchDateRangeResolverTests +{ + [Fact] + public void Resolve_ThisWeek_UsesIsoMonday() + { + var reference = new DateOnly(2026, 6, 24); + var range = SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve( + SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + null, + null, + reference); + + Assert.Equal(new DateOnly(2026, 6, 22), range.From); + Assert.Equal(new DateOnly(2026, 6, 28), range.To); + } + + [Fact] + public void Resolve_Custom_RequiresDates() + { + var ex = Assert.Throws(() => + SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve( + SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + null, + null)); + + Assert.Contains("dateFrom", ex.Message); + } +} + +public class WorkOrderAdvancedSearchServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task SearchAsync_FiltersBySiteAndPaginates() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(6), + Sites = new List { "BK5" }, + Page = 1, + PageSize = 50 + }, null); + + Assert.Equal(1, result.TotalCount); + Assert.Single(result.Items); + Assert.Equal("BK5", result.Items.First().SiteCode); + } + + [Fact] + public async Task SearchAsync_ClampsPageSizeToMax() + { + await using var context = CreateContext(); + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + PageSize = 500 + }, null); + + Assert.Equal(100, result.PageSize); + } +} diff --git a/scripts/load-test/README.md b/scripts/load-test/README.md new file mode 100644 index 0000000..c068043 --- /dev/null +++ b/scripts/load-test/README.md @@ -0,0 +1,40 @@ +# Load Test — Work Order Search (Tier S) + +Harness k6 para validar SLOs da Fase 4 em ambiente local ou staging. + +## Pré-requisitos + +- [k6](https://k6.io/docs/get-started/installation/) instalado +- API rodando com JWT válido +- (Opcional) Seed sintético: `scripts/seed-work-orders-search.ps1 -Count 15000` + +## Variáveis de ambiente + +| Variável | Descrição | Default | +|----------|-----------|---------| +| `BASE_URL` | URL base da API | `https://localhost:5001` | +| `JWT` | Bearer token | _(obrigatório)_ | +| `WEEK_START` | Segunda-feira ISO para board | `2026-06-22` | + +## Execução + +```powershell +$env:JWT = "" +$env:BASE_URL = "https://localhost:5001" +k6 run scripts/load-test/work-order-search.k6.js +``` + +## SLOs Tier S (p95) + +| Métrica | Limite | +|---------|--------| +| `board_no_search` | < 800ms | +| `board_with_search` | < 1000ms | +| `advanced_preset` | < 1200ms | +| `advanced_multi_filter` | < 1500ms | + +## Staging (pós Volume Discovery) + +1. Executar `scripts/volume-discovery.ps1` +2. Confirmar tier em `docs/work-orders/phase-4/search-tier-decision.md` +3. Repetir k6 com volume real; ajustar thresholds se tier = M diff --git a/scripts/load-test/work-order-search.k6.js b/scripts/load-test/work-order-search.k6.js new file mode 100644 index 0000000..ff69d67 --- /dev/null +++ b/scripts/load-test/work-order-search.k6.js @@ -0,0 +1,60 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; + +const boardNoSearch = new Trend('board_no_search', true); +const boardWithSearch = new Trend('board_with_search', true); +const advancedPreset = new Trend('advanced_preset', true); +const advancedMultiFilter = new Trend('advanced_multi_filter', true); + +const BASE_URL = __ENV.BASE_URL || 'https://localhost:5001'; +const JWT = __ENV.JWT || ''; +const WEEK_START = __ENV.WEEK_START || '2026-06-22'; + +export const options = { + vus: 10, + duration: '30s', + thresholds: { + board_no_search: ['p(95)<800'], + board_with_search: ['p(95)<1000'], + advanced_preset: ['p(95)<1200'], + advanced_multi_filter: ['p(95)<1500'], + }, +}; + +const headers = { + Authorization: `Bearer ${JWT}`, + Accept: 'application/json', +}; + +export default function () { + const boardRes = http.get( + `${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}`, + { headers } + ); + boardNoSearch.add(boardRes.timings.duration); + check(boardRes, { 'board 200': (r) => r.status === 200 }); + + const searchRes = http.get( + `${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}&search=BK5`, + { headers } + ); + boardWithSearch.add(searchRes.timings.duration); + check(searchRes, { 'board search 200': (r) => r.status === 200 }); + + const advRes = http.get( + `${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&page=1&pageSize=50`, + { headers } + ); + advancedPreset.add(advRes.timings.duration); + check(advRes, { 'advanced preset 200': (r) => r.status === 200 }); + + const multiRes = http.get( + `${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&sites=BK5&types=PM&search=HVAC&page=1`, + { headers } + ); + advancedMultiFilter.add(multiRes.timings.duration); + check(multiRes, { 'advanced multi 200': (r) => r.status === 200 }); + + sleep(0.5); +} From 54cd24acf426f86948ccd21788b11506a190bf6b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:36:31 -0300 Subject: [PATCH 18/30] fix(migrations): shrink SiteCode and InternalWONumber before Phase4 search indexes SQL Server cannot index nvarchar(max). Alter columns to nvarchar(450) before creating Phase 4 search indexes. --- .../20260624200000_Phase4_SearchIndexes.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs index 427b6cf..51795a3 100644 --- a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs @@ -10,6 +10,27 @@ namespace Data.SeaHavenIndustries.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { + // nvarchar(max) cannot be an index key; shrink before indexing. + migrationBuilder.AlterColumn( + name: "SiteCode", + table: "workOrders", + type: "nvarchar(450)", + maxLength: 450, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "InternalWONumber", + table: "workOrders", + type: "nvarchar(450)", + maxLength: 450, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + migrationBuilder.CreateIndex( name: "IX_workOrders_SiteCode", table: "workOrders", @@ -43,6 +64,26 @@ namespace Data.SeaHavenIndustries.Migrations migrationBuilder.DropIndex( name: "IX_workOrders_LifecycleStatus_ScheduledDate", table: "workOrders"); + + migrationBuilder.AlterColumn( + name: "SiteCode", + table: "workOrders", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(450)", + oldMaxLength: 450, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "InternalWONumber", + table: "workOrders", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(450)", + oldMaxLength: 450, + oldNullable: true); } } } From edcdc1039535af225c7ec4b88272996067509485 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 10:56:18 -0300 Subject: [PATCH 19/30] fix(work-orders): return 400 for invalid advanced-search custom date range Map ArgumentException from datePreset=custom without dates to BadRequest so the endpoint matches the Phase 4 contract. --- .../WorkOrderControllerSearchTests.cs | 101 ++++++++++++++++++ .../Controllers/WorkOrderController.cs | 13 ++- 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs new file mode 100644 index 0000000..61d37c9 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -0,0 +1,101 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderControllerSearchTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static UserManager MockUserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object; + } + + private static WorkOrderController NewController( + ApplicationDbContext ctx, + IWorkOrderAdvancedSearchService advancedSearch) + { + var controller = new WorkOrderController( + MockUserManager(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + ctx, + Mock.Of(), + Mock.Of(), + null!, + null!, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + advancedSearch); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + new[] + { + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "test-user") + }, + "Test")) + } + }; + + return controller; + } + + [Fact] + public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() + { + using var ctx = NewContext(); + var advancedSearch = new WorkOrderAdvancedSearchService( + Mock.Of()); + var controller = NewController(ctx, advancedSearch); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom + }); + + var badRequest = result.Should().BeOfType().Subject; + var body = badRequest.Value.Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Contain("dateFrom"); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 6d597a3..57a7ac1 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -127,9 +127,16 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("board/search")] public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var result = await _advancedSearchService.SearchAsync(query, userId); - return Ok(result); + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _advancedSearchService.SearchAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("lookups/dispatchers")] From 31675e49dd00aebfffc624b1426ed6f1a466264b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:45:24 -0300 Subject: [PATCH 20/30] fix(work-orders): make Phase4 search index migration discoverable by EF --- .../Auth/ApplicationDbContext.cs | 12 + ...624200000_Phase4_SearchIndexes.Designer.cs | 3006 +++++++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 9 + 3 files changed, 3027 insertions(+) create mode 100644 Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 7cce9c8..a055fcd 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -42,6 +42,18 @@ namespace Data.SeaHavenIndustries .Property(w => w.RowVersion) .IsRowVersion(); + builder.Entity() + .HasIndex(w => w.SiteCode) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .HasIndex(w => w.InternalWONumber) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) + .HasFilter("[istemplate] = 0"); + builder.Entity() .Property(d => d.RowVersion) .IsRowVersion(); diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs new file mode 100644 index 0000000..ee0a600 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs @@ -0,0 +1,3006 @@ +// +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("20260624200000_Phase4_SearchIndexes")] + partial class Phase4_SearchIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 061ccd8..0a169ec 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2033,12 +2033,21 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("AssignTo", "ScheduledDate"); + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + b.HasIndex("LocationId"); b.HasIndex("PrimaryDispatchId"); b.HasIndex("ScheduledDate"); + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + b.HasIndex("WorkOrderType"); b.ToTable("workOrders"); From bdd566de83fe6130b6db467501a1850d315c7dc7 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 13:42:16 -0300 Subject: [PATCH 21/30] fix(work-orders): bound SiteCode/InternalWONumber so Phase4 indexes apply on SQL Server --- .../Auth/ApplicationDbContext.cs | 10 + ...624200000_Phase4_SearchIndexes.Designer.cs | 6 +- .../20260624200000_Phase4_SearchIndexes.cs | 25 +-- .../ApplicationDbContextModelSnapshot.cs | 6 +- .../Phase4SearchIndexesSqlServerTests.cs | 177 ++++++++++++++++++ 5 files changed, 205 insertions(+), 19 deletions(-) create mode 100644 SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a055fcd..9dbef87 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -42,6 +42,16 @@ namespace Data.SeaHavenIndustries .Property(w => w.RowVersion) .IsRowVersion(); + // Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable). + // SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits. + builder.Entity() + .Property(w => w.SiteCode) + .HasMaxLength(32); + + builder.Entity() + .Property(w => w.InternalWONumber) + .HasMaxLength(11); + builder.Entity() .HasIndex(w => w.SiteCode) .HasFilter("[istemplate] = 0"); diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs index ee0a600..e860475 100644 --- a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs @@ -1911,7 +1911,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("InternalWONumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); b.Property("IsDeleted") .HasColumnType("bit"); @@ -1989,7 +1990,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("SiteCode") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); b.Property("Source") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs index 51795a3..c92d732 100644 --- a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs @@ -10,26 +10,23 @@ namespace Data.SeaHavenIndustries.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { - // nvarchar(max) cannot be an index key; shrink before indexing. migrationBuilder.AlterColumn( name: "SiteCode", table: "workOrders", - type: "nvarchar(450)", - maxLength: 450, + type: "nvarchar(32)", + maxLength: 32, nullable: true, oldClrType: typeof(string), - oldType: "nvarchar(max)", - oldNullable: true); + oldType: "nvarchar(max)"); migrationBuilder.AlterColumn( name: "InternalWONumber", table: "workOrders", - type: "nvarchar(450)", - maxLength: 450, + type: "nvarchar(11)", + maxLength: 11, nullable: true, oldClrType: typeof(string), - oldType: "nvarchar(max)", - oldNullable: true); + oldType: "nvarchar(max)"); migrationBuilder.CreateIndex( name: "IX_workOrders_SiteCode", @@ -71,9 +68,8 @@ namespace Data.SeaHavenIndustries.Migrations type: "nvarchar(max)", nullable: true, oldClrType: typeof(string), - oldType: "nvarchar(450)", - oldMaxLength: 450, - oldNullable: true); + oldType: "nvarchar(32)", + oldMaxLength: 32); migrationBuilder.AlterColumn( name: "InternalWONumber", @@ -81,9 +77,8 @@ namespace Data.SeaHavenIndustries.Migrations type: "nvarchar(max)", nullable: true, oldClrType: typeof(string), - oldType: "nvarchar(450)", - oldMaxLength: 450, - oldNullable: true); + oldType: "nvarchar(11)", + oldMaxLength: 11); } } } diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 0a169ec..ee87ae1 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1908,7 +1908,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("InternalWONumber") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); b.Property("IsDeleted") .HasColumnType("bit"); @@ -1986,7 +1987,8 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("nvarchar(max)"); b.Property("SiteCode") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); b.Property("Source") .HasColumnType("nvarchar(max)"); diff --git a/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs b/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs new file mode 100644 index 0000000..0ed2280 --- /dev/null +++ b/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs @@ -0,0 +1,177 @@ +using System.Reflection; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Migrations; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace SeaHavenIndustries.Tests; + +public class Phase4SearchIndexesSqlServerTests +{ + private const string LocalDbMaster = + @"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3"; + + [Fact] + public void WorkOrder_IndexedStringColumns_HaveBoundedMaxLengths() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(LocalDbMaster) + .Options; + + using var context = new ApplicationDbContext(options); + var entity = context.Model.FindEntityType(typeof(WorkOrder)); + Assert.NotNull(entity); + + Assert.Equal(32, entity.FindProperty(nameof(WorkOrder.SiteCode))!.GetMaxLength()); + Assert.Equal(11, entity.FindProperty(nameof(WorkOrder.InternalWONumber))!.GetMaxLength()); + } + + [Fact] + public async Task Phase4_SearchIndexes_ApplyOnSqlServer_WhenLocalDbAvailable() + { + if (!await IsLocalDbAvailableAsync()) + return; + + var dbName = $"Phase4SearchIndexes_{Guid.NewGuid():N}"; + var connectionString = + $@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True"; + + try + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + await using (var createTable = connection.CreateCommand()) + { + createTable.CommandText = + """ + CREATE TABLE workOrders ( + Id int NOT NULL IDENTITY PRIMARY KEY, + SiteCode nvarchar(max) NULL, + InternalWONumber nvarchar(max) NULL, + LifecycleStatus int NULL, + ScheduledDate datetime2 NULL, + istemplate bit NULL + ); + """; + await createTable.ExecuteNonQueryAsync(); + } + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connection) + .Options; + + await using var context = new ApplicationDbContext(options); + await ApplyMigrationUpAsync(context, new Phase4_SearchIndexes()); + + await using (var assertCmd = connection.CreateCommand()) + { + assertCmd.CommandText = + """ + SELECT c.name, t.name AS type_name, c.max_length + FROM sys.columns c + INNER JOIN sys.types t ON c.user_type_id = t.user_type_id + WHERE c.object_id = OBJECT_ID(N'workOrders') + AND c.name IN (N'SiteCode', N'InternalWONumber') + ORDER BY c.name; + """; + + await using var reader = await assertCmd.ExecuteReaderAsync(); + var columns = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (await reader.ReadAsync()) + { + columns[reader.GetString(0)] = (reader.GetString(1), reader.GetInt16(2)); + } + + Assert.Equal("nvarchar", columns["InternalWONumber"].Type); + Assert.Equal(22, columns["InternalWONumber"].MaxLength); // nvarchar(11) => 22 bytes + Assert.Equal("nvarchar", columns["SiteCode"].Type); + Assert.Equal(64, columns["SiteCode"].MaxLength); // nvarchar(32) => 64 bytes + } + + await using (var indexCmd = connection.CreateCommand()) + { + indexCmd.CommandText = + """ + SELECT name + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'workOrders') + AND name IN ( + N'IX_workOrders_SiteCode', + N'IX_workOrders_InternalWONumber', + N'IX_workOrders_LifecycleStatus_ScheduledDate' + ); + """; + + await using var reader = await indexCmd.ExecuteReaderAsync(); + var indexes = new List(); + while (await reader.ReadAsync()) + indexes.Add(reader.GetString(0)); + + Assert.Contains("IX_workOrders_SiteCode", indexes); + Assert.Contains("IX_workOrders_InternalWONumber", indexes); + Assert.Contains("IX_workOrders_LifecycleStatus_ScheduledDate", indexes); + } + } + finally + { + await DropDatabaseAsync(dbName); + } + } + + private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration) + { + var builder = new MigrationBuilder(context.Database.ProviderName!); + var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Migration.Up not found."); + up.Invoke(migration, [builder]); + + var sqlGenerator = context.GetService(); + var commands = sqlGenerator.Generate(builder.Operations, model: null); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task IsLocalDbAvailableAsync() + { + try + { + await using var connection = new SqlConnection(LocalDbMaster); + await connection.OpenAsync(); + return true; + } + catch (SqlException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static async Task DropDatabaseAsync(string dbName) + { + try + { + await using var connection = new SqlConnection(LocalDbMaster); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = + $""" + IF DB_ID(N'{dbName}') IS NOT NULL + BEGIN + ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{dbName}]; + END + """; + await command.ExecuteNonQueryAsync(); + } + catch + { + // Best-effort cleanup for ephemeral LocalDB databases. + } + } +} From 7459dca3c611534198a468d9ae2211100ac15a82 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 9 Jul 2026 14:08:48 -0300 Subject: [PATCH 22/30] feat(work-orders): add Phase 5 scheduled domain events (WeekRolled) Introduce in-process WeekRolled job with idempotent ledger to increment carriedOver for the SHOC board, plus optional PastDue cache and admin reprocess endpoints while keeping isPastDue derived on-read. --- .../Controllers/WorkOrderJobsController.cs | 58 ++++ .../PastDueCacheHostedService.cs | 74 +++++ .../WorkOrderWeekRolledHostedService.cs | 82 +++++ .../Options/WorkOrderJobRunState.cs | 11 + .../Options/WorkOrderJobsOptions.cs | 23 ++ Api.SeaHavenIndustries/Program.cs | 8 + Api.SeaHavenIndustries/appsettings.json | 4 + .../Auth/ApplicationDbContext.cs | 5 + .../20260709170202_Phase5_DomainEvents.cs | 50 +++ .../ApplicationDbContextModelSnapshot.cs | 118 ++++--- .../Models/WorkOrderWeekRolledLedger.cs | 15 + .../WorkOrderDomainJobDataService.cs | 166 ++++++++++ .../IWorkOrderDomainJobDataService.cs | 38 +++ SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs | 21 ++ .../Implementation/PastDueCacheService.cs | 48 +++ .../Implementation/WorkOrderAuditService.cs | 11 + .../WorkOrderWeekRolledService.cs | 111 +++++++ .../Interfaces/IPastDueCacheService.cs | 9 + .../Interfaces/IWorkOrderAuditService.cs | 1 + .../Interfaces/IWorkOrderWeekRolledService.cs | 11 + .../WorkOrderWeekRolledTests.cs | 311 ++++++++++++++++++ 21 files changed, 1136 insertions(+), 39 deletions(-) create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs create mode 100644 Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs create mode 100644 Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs create mode 100644 Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs create mode 100644 SeaHaven.Services/Implementation/PastDueCacheService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs create mode 100644 SeaHaven.Services/Interfaces/IPastDueCacheService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs new file mode 100644 index 0000000..4277475 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs @@ -0,0 +1,58 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + /// Ops-only endpoints for manual domain job reprocessing. + [Authorize(Roles = "Admin")] + [ApiController] + [Route("api/workorders/jobs")] + public class WorkOrderJobsController : ControllerBase + { + private readonly IWorkOrderWeekRolledService _weekRolledService; + private readonly IPastDueCacheService _pastDueCacheService; + + public WorkOrderJobsController( + IWorkOrderWeekRolledService weekRolledService, + IPastDueCacheService pastDueCacheService) + { + _weekRolledService = weekRolledService; + _pastDueCacheService = pastDueCacheService; + } + + /// + /// Reprocess WeekRolled for a specific operational week (Monday start). + /// Idempotent via WorkOrderWeekRolledLedger. + /// + [HttpPost("week-rolled")] + [ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)] + public async Task> RunWeekRolled( + [FromQuery] DateOnly? sourceWeekStart, + CancellationToken cancellationToken) + { + var weekStart = sourceWeekStart + ?? WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow); + + if (weekStart.DayOfWeek != DayOfWeek.Monday) + return BadRequest("sourceWeekStart must be a Monday (operational week start)."); + + var result = await _weekRolledService.ProcessWeekRolledAsync(weekStart, cancellationToken); + return Ok(result); + } + + /// + /// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read). + /// + [HttpPost("past-due-cache")] + [ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)] + public async Task> RunPastDueCache(CancellationToken cancellationToken) + { + var result = await _pastDueCacheService.RefreshAsync(cancellationToken); + return Ok(result); + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs new file mode 100644 index 0000000..67b8cba --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs @@ -0,0 +1,74 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class PastDueCacheHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly PastDueCacheJobOptions _options; + private readonly WorkOrderJobRunState _runState; + private DateOnly? _lastRunDate; + + public PastDueCacheHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + WorkOrderJobRunState runState, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value.PastDueCache; + _runState = runState; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_options.Enabled && ShouldRunNow()) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastRunDate != today) + { + _lastRunDate = today; + await RunJobAsync(stoppingToken); + } + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task RunJobAsync(CancellationToken stoppingToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.RefreshAsync(stoppingToken); + + _runState.LastPastDueCacheRunUtc = DateTime.UtcNow; + _runState.LastPastDueCacheError = null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _runState.LastPastDueCacheError = ex.Message; + _logger.LogError(ex, "PastDue cache hosted job failed."); + } + } + + private bool ShouldRunNow() + { + var now = DateTime.UtcNow; + + if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) + runAt = new TimeOnly(0, 10); + + var current = TimeOnly.FromDateTime(now); + return current >= runAt && current < runAt.AddMinutes(1); + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs new file mode 100644 index 0000000..7caf0a2 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs @@ -0,0 +1,82 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class WorkOrderWeekRolledHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly WeekRolledJobOptions _options; + private readonly WorkOrderJobRunState _runState; + private DateOnly? _lastRunDate; + + public WorkOrderWeekRolledHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + WorkOrderJobRunState runState, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value.WeekRolled; + _runState = runState; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_options.Enabled && ShouldRunNow()) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastRunDate != today) + { + _lastRunDate = today; + await RunJobAsync(stoppingToken); + } + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task RunJobAsync(CancellationToken stoppingToken) + { + try + { + var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow); + + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.ProcessWeekRolledAsync(sourceWeekStart, stoppingToken); + + _runState.LastWeekRolledRunUtc = DateTime.UtcNow; + _runState.LastWeekRolledError = null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _runState.LastWeekRolledError = ex.Message; + _logger.LogError(ex, "WeekRolled hosted job failed."); + } + } + + private bool ShouldRunNow() + { + var now = DateTime.UtcNow; + if (!Enum.TryParse(_options.DayOfWeek, ignoreCase: true, out var targetDay)) + targetDay = DayOfWeek.Monday; + + if (now.DayOfWeek != targetDay) + return false; + + if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) + runAt = new TimeOnly(0, 5); + + var current = TimeOnly.FromDateTime(now); + return current >= runAt && current < runAt.AddMinutes(1); + } + } +} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs new file mode 100644 index 0000000..dd95de5 --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs @@ -0,0 +1,11 @@ +namespace Api.SeaHavenIndustries.Options +{ + /// In-memory last-run timestamps for hosted domain jobs (ops health). + public class WorkOrderJobRunState + { + public DateTime? LastWeekRolledRunUtc { get; set; } + public DateTime? LastPastDueCacheRunUtc { get; set; } + public string? LastWeekRolledError { get; set; } + public string? LastPastDueCacheError { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs new file mode 100644 index 0000000..791638c --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs @@ -0,0 +1,23 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class WorkOrderJobsOptions + { + public const string SectionName = "WorkOrderJobs"; + + public WeekRolledJobOptions WeekRolled { get; set; } = new(); + public PastDueCacheJobOptions PastDueCache { get; set; } = new(); + } + + public class WeekRolledJobOptions + { + public bool Enabled { get; set; } = true; + public string RunAtUtc { get; set; } = "00:05"; + public string DayOfWeek { get; set; } = "Monday"; + } + + public class PastDueCacheJobOptions + { + public bool Enabled { get; set; } + public string RunAtUtc { get; set; } = "00:10"; + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 55d5a4d..bb40d8c 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,4 +1,6 @@ using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; @@ -52,6 +54,12 @@ builder.Services.AddSingleton(sp builder.Services.AddDataServices(); builder.Services.AddBusinessServices(); +builder.Services.Configure( + builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index 2d5dc80..f023879 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -23,5 +23,9 @@ "ValidIssuer": "http://localhost:7195", // Provide the real value via environment variable JWT__Secret or user-secrets. "Secret": "${JWT_SECRET}" + }, + "WorkOrderJobs": { + "WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" }, + "PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" } } } \ No newline at end of file diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 9dbef87..0599946 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -38,6 +38,10 @@ namespace Data.SeaHavenIndustries .HasIndex(l => new { l.WorkOrderId, l.FieldName }) .IsUnique(); + builder.Entity() + .HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart }) + .IsUnique(); + builder.Entity() .Property(w => w.RowVersion) .IsRowVersion(); @@ -88,6 +92,7 @@ namespace Data.SeaHavenIndustries public DbSet workOrderAttachments { get; set; } public DbSet WorkOrderAuditLogs { get; set; } public DbSet WorkOrderFieldLocks { get; set; } + public DbSet WorkOrderWeekRolledLedgers { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } public DbSet VendorAccessTokens { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs new file mode 100644 index 0000000..6a0307f --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase5_DomainEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WorkOrderWeekRolledLedgers", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + WorkOrderId = table.Column(type: "int", nullable: false), + SourceWeekStart = table.Column(type: "date", nullable: false), + ProcessedAt = table.Column(type: "datetime2", nullable: false), + CorrelationId = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkOrderWeekRolledLedgers", x => x.Id); + table.ForeignKey( + name: "FK_WorkOrderWeekRolledLedgers_workOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "workOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrderWeekRolledLedgers_WorkOrderId_SourceWeekStart", + table: "WorkOrderWeekRolledLedgers", + columns: new[] { "WorkOrderId", "SourceWeekStart" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WorkOrderWeekRolledLedgers"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index ee87ae1..4f0969e 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Accounts"); + b.ToTable("Accounts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => @@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("Addresses"); + b.ToTable("Addresses", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => @@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Assets"); + b.ToTable("Assets", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => @@ -371,7 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Categories"); + b.ToTable("Categories", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => @@ -438,7 +438,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkerOrderId"); - b.ToTable("Comments"); + b.ToTable("Comments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => @@ -504,7 +504,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("ContactDetails"); + b.ToTable("ContactDetails", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => @@ -596,7 +596,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Contacts"); + b.ToTable("Contacts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => @@ -614,7 +614,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Departments"); + b.ToTable("Departments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => @@ -705,7 +705,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("Dispatches"); + b.ToTable("Dispatches", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => @@ -764,7 +764,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchChecklistItems"); + b.ToTable("DispatchChecklistItems", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => @@ -818,7 +818,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("DispatchId"); - b.ToTable("DispatchSignoffs"); + b.ToTable("DispatchSignoffs", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => @@ -888,7 +888,7 @@ namespace Data.SeaHavenIndustries.Migrations .IsUnique() .HasFilter("[Status] = 'Pending'"); - b.ToTable("DispatchUpliftRequests"); + b.ToTable("DispatchUpliftRequests", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => @@ -911,7 +911,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchWorkOrders"); + b.ToTable("DispatchWorkOrders", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => @@ -941,7 +941,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("DropdownOptions"); + b.ToTable("DropdownOptions", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => @@ -1046,7 +1046,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("RegionId"); - b.ToTable("Employees"); + b.ToTable("Employees", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => @@ -1086,7 +1086,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId") .IsUnique(); - b.ToTable("EmployeeAddresses"); + b.ToTable("EmployeeAddresses", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => @@ -1114,7 +1114,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeeEmails"); + b.ToTable("EmployeeEmails", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => @@ -1144,7 +1144,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeePhones"); + b.ToTable("EmployeePhones", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => @@ -1212,7 +1212,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("FollowUps"); + b.ToTable("FollowUps", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => @@ -1237,7 +1237,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("ForgetPasswordCodes"); + b.ToTable("ForgetPasswordCodes", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => @@ -1255,7 +1255,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("JobTitles"); + b.ToTable("JobTitles", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => @@ -1325,7 +1325,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Locations"); + b.ToTable("Locations", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => @@ -1383,7 +1383,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("PMSchedules"); + b.ToTable("PMSchedules", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => @@ -1457,7 +1457,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("Quotes"); + b.ToTable("Quotes", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => @@ -1505,7 +1505,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("QuoteId"); - b.ToTable("QuotesLines"); + b.ToTable("QuotesLines", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => @@ -1523,7 +1523,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Regions"); + b.ToTable("Regions", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => @@ -1566,7 +1566,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("TaskListTemplates"); + b.ToTable("TaskListTemplates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => @@ -1611,7 +1611,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("TaskListTemplateId"); - b.ToTable("TaskListTemplateItems"); + b.ToTable("TaskListTemplateItems", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => @@ -1713,7 +1713,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Templates"); + b.ToTable("Templates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => @@ -1777,7 +1777,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Vendors"); + b.ToTable("Vendors", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => @@ -1836,7 +1836,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("VendorId"); - b.ToTable("VendorAccessTokens"); + b.ToTable("VendorAccessTokens", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => @@ -1970,13 +1970,13 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("ScheduledStart") .HasColumnType("datetime2"); - b.Property("Severity") - .HasColumnType("nvarchar(max)"); - b.Property("Service") .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + b.Property("SignOffAttachment") .HasColumnType("nvarchar(max)"); @@ -2094,7 +2094,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderAttachments"); + b.ToTable("workOrderAttachments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => @@ -2144,7 +2144,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("WorkOrderAuditLogs"); + b.ToTable("WorkOrderAuditLogs", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => @@ -2188,7 +2188,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderCategories"); + b.ToTable("workOrderCategories", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => @@ -2235,7 +2235,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("WorkOrderContacts"); + b.ToTable("WorkOrderContacts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => @@ -2264,7 +2264,36 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId", "FieldName") .IsUnique(); - b.ToTable("WorkOrderFieldLocks"); + b.ToTable("WorkOrderFieldLocks", (string)null); + }); + + 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", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => @@ -2833,6 +2862,17 @@ namespace Data.SeaHavenIndustries.Migrations 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("Data.SeaHavenIndustries.WorkOrderFieldLock", b => { b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") diff --git a/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs new file mode 100644 index 0000000..efa661a --- /dev/null +++ b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class WorkOrderWeekRolledLedger + { + public int Id { get; set; } + public int WorkOrderId { get; set; } + [ForeignKey(nameof(WorkOrderId))] + public virtual WorkOrder? WorkOrder { get; set; } + public DateOnly SourceWeekStart { get; set; } + public DateTime ProcessedAt { get; set; } + public string CorrelationId { get; set; } = ""; + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs new file mode 100644 index 0000000..2d8433a --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs @@ -0,0 +1,166 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderDomainJobDataService : IWorkOrderDomainJobDataService + { + private static readonly LifecycleStatus?[] TerminalStatuses = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + + private readonly ApplicationDbContext _context; + + public WorkOrderDomainJobDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetWeekRolledCandidatesAsync( + DateOnly sourceWeekStart, + DateOnly sourceWeekEnd, + int batchSize = 500, + int skip = 0, + CancellationToken cancellationToken = default) + { + var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue); + var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue); + + return await _context.workOrders + .AsNoTracking() + .Where(w => w.IsDeleted != true) + .Where(w => w.istemplate != true) + .Where(w => w.ScheduledDate != null) + .Where(w => + w.ScheduledDate!.Value.Date >= weekStartDate.Date + && w.ScheduledDate.Value.Date <= weekEndDate.Date) + .Where(w => !TerminalStatuses.Contains(w.LifecycleStatus)) + .Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l => + l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart)) + .OrderBy(w => w.Id) + .Skip(skip) + .Take(batchSize) + .Select(w => w.Id) + .ToListAsync(cancellationToken); + } + + public async Task TryProcessWeekRolledAsync( + int workOrderId, + DateOnly sourceWeekStart, + string correlationId, + CancellationToken cancellationToken = default) + { + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + + var alreadyProcessed = await _context.WorkOrderWeekRolledLedgers + .AnyAsync(l => l.WorkOrderId == workOrderId && l.SourceWeekStart == sourceWeekStart, cancellationToken); + + if (alreadyProcessed) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + var workOrder = await _context.workOrders + .FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + + if (workOrder == null) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + _context.WorkOrderWeekRolledLedgers.Add(new WorkOrderWeekRolledLedger + { + WorkOrderId = workOrderId, + SourceWeekStart = sourceWeekStart, + ProcessedAt = DateTime.UtcNow, + CorrelationId = correlationId + }); + + var oldCarriedOver = workOrder.CarriedOver; + workOrder.CarriedOver = oldCarriedOver + 1; + + try + { + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateException) + { + await transaction.RollbackAsync(cancellationToken); + return new WeekRolledProcessOutcome { Skipped = true }; + } + + return new WeekRolledProcessOutcome + { + Processed = true, + OldCarriedOver = oldCarriedOver, + NewCarriedOver = workOrder.CarriedOver + }; + } + + public async Task RefreshPastDueFlagsAsync( + DateTime utcNow, + int batchSize = 500, + CancellationToken cancellationToken = default) + { + var today = utcNow.Date; + var setCount = 0; + var clearedCount = 0; + var examinedCount = 0; + var skip = 0; + + while (true) + { + var batch = await _context.workOrders + .Where(w => w.IsDeleted != true) + .Where(w => w.istemplate != true) + .Where(w => w.ScheduledDate != null) + .OrderBy(w => w.Id) + .Skip(skip) + .Take(batchSize) + .ToListAsync(cancellationToken); + + if (batch.Count == 0) + break; + + foreach (var workOrder in batch) + { + examinedCount++; + var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today + && !TerminalStatuses.Contains(workOrder.LifecycleStatus); + var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue); + + if (shouldBePastDue && !hasFlag) + { + workOrder.OperationalFlags |= OperationalFlags.PastDue; + setCount++; + } + else if (!shouldBePastDue && hasFlag) + { + workOrder.OperationalFlags &= ~OperationalFlags.PastDue; + clearedCount++; + } + } + + await _context.SaveChangesAsync(cancellationToken); + skip += batchSize; + + if (batch.Count < batchSize) + break; + } + + return new PastDueRefreshResult + { + SetCount = setCount, + ClearedCount = clearedCount, + ExaminedCount = examinedCount + }; + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs new file mode 100644 index 0000000..4c6af01 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs @@ -0,0 +1,38 @@ +namespace SeaHaven.DataServices.Interfaces +{ + public sealed class WeekRolledProcessOutcome + { + public bool Processed { get; init; } + public bool Skipped { get; init; } + public int OldCarriedOver { get; init; } + public int NewCarriedOver { get; init; } + } + + public sealed class PastDueRefreshResult + { + public int SetCount { get; init; } + public int ClearedCount { get; init; } + public int ExaminedCount { get; init; } + } + + public interface IWorkOrderDomainJobDataService + { + Task> GetWeekRolledCandidatesAsync( + DateOnly sourceWeekStart, + DateOnly sourceWeekEnd, + int batchSize = 500, + int skip = 0, + CancellationToken cancellationToken = default); + + Task TryProcessWeekRolledAsync( + int workOrderId, + DateOnly sourceWeekStart, + string correlationId, + CancellationToken cancellationToken = default); + + Task RefreshPastDueFlagsAsync( + DateTime utcNow, + int batchSize = 500, + CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs new file mode 100644 index 0000000..885c363 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs @@ -0,0 +1,21 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class WeekRolledJobResult + { + public int Processed { get; set; } + public int Skipped { get; set; } + public int Failed { get; set; } + public string CorrelationId { get; set; } = ""; + public DateOnly SourceWeekStart { get; set; } + public DateOnly SourceWeekEnd { get; set; } + public long DurationMs { get; set; } + } + + public sealed class PastDueCacheJobResult + { + public int SetCount { get; set; } + public int ClearedCount { get; set; } + public int ExaminedCount { get; set; } + public long DurationMs { get; set; } + } +} diff --git a/SeaHaven.Services/Implementation/PastDueCacheService.cs b/SeaHaven.Services/Implementation/PastDueCacheService.cs new file mode 100644 index 0000000..33c1646 --- /dev/null +++ b/SeaHaven.Services/Implementation/PastDueCacheService.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class PastDueCacheService : IPastDueCacheService + { + private readonly IWorkOrderDomainJobDataService _dataService; + private readonly ILogger _logger; + + public PastDueCacheService( + IWorkOrderDomainJobDataService dataService, + ILogger logger) + { + _dataService = dataService; + _logger = logger; + } + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var utcNow = DateTime.UtcNow; + + _logger.LogInformation("PastDue cache refresh started at {UtcNow}", utcNow); + + var refreshResult = await _dataService.RefreshPastDueFlagsAsync(utcNow, cancellationToken: cancellationToken); + + stopwatch.Stop(); + + var result = new PastDueCacheJobResult + { + SetCount = refreshResult.SetCount, + ClearedCount = refreshResult.ClearedCount, + ExaminedCount = refreshResult.ExaminedCount, + DurationMs = stopwatch.ElapsedMilliseconds + }; + + _logger.LogInformation( + "PastDue cache refresh completed. Set={SetCount}, Cleared={ClearedCount}, Examined={ExaminedCount}, DurationMs={DurationMs}", + result.SetCount, result.ClearedCount, result.ExaminedCount, result.DurationMs); + + return result; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index b0e7c60..51df1cc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -28,6 +28,17 @@ namespace SeaHaven.Services.Implementation => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, AuditActorType.Sync, fieldName, oldValue, newValue); + public Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId) + => LogAsync( + workOrderId, + AuditActionType.WeekRolled, + AuditEventType.System, + AuditActorType.System, + "CarriedOver", + oldCarriedOver.ToString(), + newCarriedOver.ToString(), + correlationId: correlationId); + public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) => StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs new file mode 100644 index 0000000..bf82574 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderWeekRolledService : IWorkOrderWeekRolledService + { + private const int BatchSize = 500; + + private readonly IWorkOrderDomainJobDataService _dataService; + private readonly IWorkOrderAuditService _auditService; + private readonly ILogger _logger; + + public WorkOrderWeekRolledService( + IWorkOrderDomainJobDataService dataService, + IWorkOrderAuditService auditService, + ILogger logger) + { + _dataService = dataService; + _auditService = auditService; + _logger = logger; + } + + public async Task ProcessWeekRolledAsync( + DateOnly sourceWeekStart, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var sourceWeekEnd = WorkOrderOperationalWeek.GetOperationalWeekEnd(sourceWeekStart); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(sourceWeekStart); + + var processed = 0; + var skipped = 0; + var failed = 0; + var skip = 0; + + _logger.LogInformation( + "WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}", + correlationId, sourceWeekStart, sourceWeekEnd); + + while (!cancellationToken.IsCancellationRequested) + { + var candidates = await _dataService.GetWeekRolledCandidatesAsync( + sourceWeekStart, sourceWeekEnd, BatchSize, skip, cancellationToken); + + if (candidates.Count == 0) + break; + + foreach (var workOrderId in candidates) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + var outcome = await _dataService.TryProcessWeekRolledAsync( + workOrderId, sourceWeekStart, correlationId, cancellationToken); + + if (outcome.Skipped) + { + skipped++; + continue; + } + + if (outcome.Processed) + { + await _auditService.LogWeekRolledAsync( + workOrderId, outcome.OldCarriedOver, outcome.NewCarriedOver, correlationId); + processed++; + } + } + catch (Exception ex) + { + failed++; + _logger.LogError(ex, + "WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}", + workOrderId, correlationId); + } + } + + if (candidates.Count < BatchSize) + break; + + skip += BatchSize; + } + + stopwatch.Stop(); + + var result = new WeekRolledJobResult + { + Processed = processed, + Skipped = skipped, + Failed = failed, + CorrelationId = correlationId, + SourceWeekStart = sourceWeekStart, + SourceWeekEnd = sourceWeekEnd, + DurationMs = stopwatch.ElapsedMilliseconds + }; + + _logger.LogInformation( + "WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}", + correlationId, processed, skipped, failed, result.DurationMs); + + return result; + } + } +} diff --git a/SeaHaven.Services/Interfaces/IPastDueCacheService.cs b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs new file mode 100644 index 0000000..e87f498 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IPastDueCacheService + { + Task RefreshAsync(CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index 3dc4b57..3970036 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -19,6 +19,7 @@ namespace SeaHaven.Services.Interfaces Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId); Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId); Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue); + Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId); Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null); Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs new file mode 100644 index 0000000..b8c1a6a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderWeekRolledService + { + Task ProcessWeekRolledAsync( + DateOnly sourceWeekStart, + CancellationToken cancellationToken = default); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs new file mode 100644 index 0000000..9627581 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -0,0 +1,311 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderWeekRolledTests +{ + private static readonly DateOnly SourceWeekStart = new(2026, 6, 16); + private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20); + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context) + { + var dataService = new WorkOrderDomainJobDataService(context); + var audit = new WorkOrderAuditService(context, new WorkOrderFieldLockService(context)); + return new WorkOrderWeekRolledService( + dataService, + audit, + NullLogger.Instance); + } + + [Fact] + public async Task WeekRolled_IncrementsCarriedOver_ForEligibleWo() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(1, result.Processed); + Assert.Equal(0, result.Skipped); + Assert.Equal(0, result.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_SkipsTerminalStatuses() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Completed + }, + new WorkOrder + { + Id = 2, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Canceled + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(0, result.Processed); + Assert.Equal(0, result.Failed); + } + + [Fact] + public async Task WeekRolled_Idempotent_SecondRunSkips() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var first = await service.ProcessWeekRolledAsync(SourceWeekStart); + var second = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(1, first.Processed); + Assert.Equal(0, second.Processed); + Assert.Equal(0, second.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_WritesAuditEvent() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.InProgress, + CarriedOver = 2 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + await service.ProcessWeekRolledAsync(SourceWeekStart); + + var logs = await context.WorkOrderAuditLogs.ToListAsync(); + Assert.Single(logs); + Assert.Equal("WeekRolled", logs[0].Action); + Assert.Equal("System", logs[0].EventType); + Assert.Equal("System", logs[0].ActorType); + Assert.Equal("CarriedOver", logs[0].FieldName); + Assert.Equal("2", logs[0].OldValue); + Assert.Equal("3", logs[0].NewValue); + Assert.Equal("week:2026-06-16", logs[0].CorrelationId); + + var locks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Empty(locks); + } + + [Fact] + public async Task WeekRolled_ConcurrentWithReschedule_DoesNotDuplicate() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart); + + var first = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + var second = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + + Assert.True(first.Processed); + Assert.True(second.Skipped); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task WeekRolled_FailureReRun_IsSafeAfterPartialLedger() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart); + + await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId); + + var service = CreateService(context); + var rerun = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(0, rerun.Processed); + Assert.Equal(0, rerun.Failed); + + var wo = await context.workOrders.FindAsync(1); + Assert.Equal(1, wo!.CarriedOver); + } + + [Fact] + public async Task PastDueCache_SyncsOperationalFlags() + { + await using var context = CreateContext(); + var pastDate = DateTime.UtcNow.Date.AddDays(-3); + var futureDate = DateTime.UtcNow.Date.AddDays(3); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Completed, + OperationalFlags = OperationalFlags.PastDue + }, + new WorkOrder + { + Id = 3, + ScheduledDate = futureDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.PastDue + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderDomainJobDataService(context); + var result = await dataService.RefreshPastDueFlagsAsync(DateTime.UtcNow); + + Assert.Equal(1, result.SetCount); + Assert.Equal(2, result.ClearedCount); + + var wo1 = await context.workOrders.FindAsync(1); + var wo2 = await context.workOrders.FindAsync(2); + var wo3 = await context.workOrders.FindAsync(3); + + Assert.True(wo1!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + Assert.False(wo2!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + Assert.False(wo3!.OperationalFlags.HasFlag(OperationalFlags.PastDue)); + } + + [Fact] + public async Task Board_ReflectsCarriedOver_AfterWeekRolled() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + await service.ProcessWeekRolledAsync(SourceWeekStart); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.Equal(1, row!.CarriedOver); + } + + [Fact] + public async Task Board_IsPastDue_UnaffectedByCache() + { + await using var context = CreateContext(); + var pastDate = DateTime.UtcNow.Date.AddDays(-2); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = pastDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.None + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.True(row!.IsPastDue); + Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false); + } + + [Fact] + public async Task Board_IsPastDue_IgnoresStalePastDueFlag() + { + await using var context = CreateContext(); + var futureDate = DateTime.UtcNow.Date.AddDays(5); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = futureDate, + LifecycleStatus = LifecycleStatus.Scheduled, + OperationalFlags = OperationalFlags.PastDue + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var row = await boardService.GetBoardRowAsync(1); + + Assert.NotNull(row); + Assert.False(row!.IsPastDue); + } +} From 93ef4f578b42c912b56f758be002760ebf8e891c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:01:52 -0300 Subject: [PATCH 23/30] fix(work-orders): process all WeekRolled candidates across batches Offset paging skipped remaining WOs after ledger writes; always refetch the first page of unprocessed candidates and cover with a >BatchSize test. --- .../WorkOrderDomainJobDataService.cs | 4 +-- .../IWorkOrderDomainJobDataService.cs | 1 - .../WorkOrderWeekRolledService.cs | 7 ++-- .../WorkOrderWeekRolledTests.cs | 36 +++++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs index 2d8433a..65d98bc 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs @@ -24,12 +24,13 @@ namespace SeaHaven.DataServices.Implementation DateOnly sourceWeekStart, DateOnly sourceWeekEnd, int batchSize = 500, - int skip = 0, CancellationToken cancellationToken = default) { var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue); var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue); + // Always return the first page of remaining unprocessed candidates. + // Callers must not offset-page: ledger writes shrink this set after each batch. return await _context.workOrders .AsNoTracking() .Where(w => w.IsDeleted != true) @@ -42,7 +43,6 @@ namespace SeaHaven.DataServices.Implementation .Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l => l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart)) .OrderBy(w => w.Id) - .Skip(skip) .Take(batchSize) .Select(w => w.Id) .ToListAsync(cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs index 4c6af01..5fac4ba 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs @@ -21,7 +21,6 @@ namespace SeaHaven.DataServices.Interfaces DateOnly sourceWeekStart, DateOnly sourceWeekEnd, int batchSize = 500, - int skip = 0, CancellationToken cancellationToken = default); Task TryProcessWeekRolledAsync( diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs index bf82574..ec4eebf 100644 --- a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs @@ -36,16 +36,17 @@ namespace SeaHaven.Services.Implementation var processed = 0; var skipped = 0; var failed = 0; - var skip = 0; _logger.LogInformation( "WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}", correlationId, sourceWeekStart, sourceWeekEnd); + // Always fetch the first page of remaining candidates. Processed rows write ledger + // entries that exclude them from the next query; offset paging would skip unprocessed rows. while (!cancellationToken.IsCancellationRequested) { var candidates = await _dataService.GetWeekRolledCandidatesAsync( - sourceWeekStart, sourceWeekEnd, BatchSize, skip, cancellationToken); + sourceWeekStart, sourceWeekEnd, BatchSize, cancellationToken); if (candidates.Count == 0) break; @@ -84,8 +85,6 @@ namespace SeaHaven.Services.Implementation if (candidates.Count < BatchSize) break; - - skip += BatchSize; } stopwatch.Stop(); diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs index 9627581..4aef509 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -193,6 +193,42 @@ public class WorkOrderWeekRolledTests Assert.Equal(1, wo!.CarriedOver); } + [Fact] + public async Task WeekRolled_ProcessesAllEligible_WhenMoreThanBatchSize() + { + // WorkOrderWeekRolledService.BatchSize is 500; more than one page must all be carried over. + const int eligibleCount = 501; + + await using var context = CreateContext(); + for (var i = 1; i <= eligibleCount; i++) + { + context.workOrders.Add(new WorkOrder + { + Id = i, + ScheduledDate = new DateTime(2026, 6, 18), + LifecycleStatus = LifecycleStatus.Scheduled, + CarriedOver = 0 + }); + } + await context.SaveChangesAsync(); + + var service = CreateService(context); + var result = await service.ProcessWeekRolledAsync(SourceWeekStart); + + Assert.Equal(eligibleCount, result.Processed); + Assert.Equal(0, result.Skipped); + Assert.Equal(0, result.Failed); + + var carriedOverCount = await context.workOrders.CountAsync(w => w.CarriedOver == 1); + Assert.Equal(eligibleCount, carriedOverCount); + + var ledgerCount = await context.WorkOrderWeekRolledLedgers.CountAsync(); + Assert.Equal(eligibleCount, ledgerCount); + + var auditCount = await context.WorkOrderAuditLogs.CountAsync(l => l.Action == "WeekRolled"); + Assert.Equal(eligibleCount, auditCount); + } + [Fact] public async Task PastDueCache_SyncsOperationalFlags() { From 8134c0b77f9b161a97991e9d928422890520d6e3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 24 Jul 2026 10:14:47 -0300 Subject: [PATCH 24/30] fix(work-orders): add Phase5 DomainEvents Designer metadata Ensure EF discovers the WeekRolled ledger migration and keeps Phase 4 column bounds in the designer snapshot. --- ...0709170202_Phase5_DomainEvents.Designer.cs | 3048 +++++++++++++++++ 1 file changed, 3048 insertions(+) create mode 100644 Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs new file mode 100644 index 0000000..7387556 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs @@ -0,0 +1,3048 @@ +// +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("20260709170202_Phase5_DomainEvents")] + partial class Phase5_DomainEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Vendors", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .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("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.ToTable("workOrders", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} From b1421de0b502a91d0af18927596de2cef5afcf91 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 11:49:57 -0300 Subject: [PATCH 25/30] fix(vendors): address roadmap review findings --- .../VendorPortalDocumentTests.cs | 30 +++++++++++++++++++ .../Controllers/VendorPortalController.cs | 6 ++-- .../appsettings.Production.json | 6 ++++ Api.SeaHavenIndustries/appsettings.json | 7 +++++ ...260723220614_AddVendorRoadmapOperations.cs | 6 ++-- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index e6b6167..be73fbb 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -124,6 +124,36 @@ public sealed class VendorPortalDocumentTests : IDisposable locked.StatusCode.Should().Be(StatusCodes.Status423Locked); } + [Fact] + public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "Application/PDF")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(1); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 }; + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(png, "photo.png", "Image/PNG")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(1); + } + public void Dispose() { if (Directory.Exists(_contentRoot)) diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index eba515f..53fbcb1 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -803,11 +803,11 @@ namespace Api.SeaHavenIndustries.Controllers { var bytes = new byte[8]; var count = await stream.ReadAsync(bytes); - if (contentType == "application/pdf") + if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase)) return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D; - if (contentType == "image/jpeg") + if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)) return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; - if (contentType == "image/png") + if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase)) return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); return false; } diff --git a/Api.SeaHavenIndustries/appsettings.Production.json b/Api.SeaHavenIndustries/appsettings.Production.json index 651d68c..b4aae5c 100644 --- a/Api.SeaHavenIndustries/appsettings.Production.json +++ b/Api.SeaHavenIndustries/appsettings.Production.json @@ -14,6 +14,12 @@ // Provide the real value via environment variable SendGrid__ApiKey or the instance secret store. "ApiKey": "${SENDGRID_API_KEY}" }, + // Production must supply a reachable ClamAV Host (environment variable ClamAV__Host). + // While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked). + "ClamAV": { + "Host": "", + "Port": 3310 + }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://console.seahavenind.com", diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index 2d5dc80..426d85b 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -17,6 +17,13 @@ // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. "ApiKey": "${SENDGRID_API_KEY}" }, + // Malware scanner for vendor completion document uploads. While Host is empty the + // scanner is considered unavailable and uploaded files stay quarantined (download + // returns 423 Locked until a scan passes). Production must supply a real Host. + "ClamAV": { + "Host": "", + "Port": 3310 + }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://localhost:4200", diff --git a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs index bea27f8..4125639 100644 --- a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs +++ b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs @@ -16,7 +16,7 @@ namespace Data.SeaHavenIndustries.Migrations table: "Vendors", type: "nvarchar(max)", nullable: false, - defaultValue: ""); + defaultValue: "Unknown"); migrationBuilder.AddColumn( name: "AvailabilityUpdatedAt", @@ -54,14 +54,14 @@ namespace Data.SeaHavenIndustries.Migrations table: "Dispatches", type: "nvarchar(max)", nullable: false, - defaultValue: ""); + defaultValue: "Not Submitted"); migrationBuilder.AddColumn( name: "PaymentStatus", table: "Dispatches", type: "nvarchar(max)", nullable: false, - defaultValue: ""); + defaultValue: "Unavailable"); migrationBuilder.CreateTable( name: "SitePreferredVendors", From af724ff625e43585d84dfbd1a27dd779fed56732 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 13:10:50 -0300 Subject: [PATCH 26/30] fix(test): guard LocalDB migration check by platform --- SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs b/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs index 0ed2280..15acce5 100644 --- a/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs +++ b/SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs @@ -136,6 +136,9 @@ public class Phase4SearchIndexesSqlServerTests private static async Task IsLocalDbAvailableAsync() { + if (!OperatingSystem.IsWindows()) + return false; + try { await using var connection = new SqlConnection(LocalDbMaster); From 78abf84b0d34ee4d113ffb783396ba3ea0f46405 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 14:27:52 -0300 Subject: [PATCH 27/30] fix(work-orders): finalize PR 22 integration compile fixes --- .../DTOs/WorkOrderBoardRequestDTOs.cs | 21 ------------------- .../WorkOrderBoardUpdateServiceTests.cs | 2 ++ 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 468e559..d93e123 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -40,27 +40,6 @@ namespace SeaHaven.Services.DTOs public string? VendorNotes { get; set; } } - /// Advanced search / filter query for the board. - public class WorkOrderAdvancedSearchQueryDto - { - public int Page { get; set; } = 0; - public int PageSize { get; set; } = 100; - public string? Search { get; set; } - public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } - public DateOnly? DateFrom { get; set; } - public DateOnly? DateTo { get; set; } - public List? Sites { get; set; } - public List? Types { get; set; } - public List? Dispatchers { get; set; } - public List? Statuses { get; set; } - public List? PmTypes { get; set; } - public List? VendorIds { get; set; } - public List? DocStatuses { get; set; } - public bool MyWorkOrders { get; set; } - public string? SortBy { get; set; } - public string? SortDir { get; set; } - } - /// Advanced search / filter query for the board. public class WorkOrderAdvancedSearchQueryDto { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index db28526..20f27e3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -2,10 +2,12 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; From 7d245eb7172711dfffd96302d6403096b833614f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 17:35:34 -0300 Subject: [PATCH 28/30] refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom --- .github/workflows/architecture-quality.yml | 51 + .../ArchitectureTests.cs | 76 + .../AuthenticationControllerTests.cs | 261 +++ .../AuthenticationServiceTests.cs | 171 ++ .../CalendarControllerTests.cs | 244 +-- .../CalendarServiceTests.cs | 188 ++ .../DashboardServiceTests.cs | 49 + .../DispatchBatchingBehaviorTests.cs | 223 +++ .../DispatchControllerTests.cs | 77 + .../DispatchServiceTests.cs | 117 ++ .../DropdownOptionsServiceTests.cs | 146 ++ .../FollowUpControllerTests.cs | 135 ++ .../FollowUpServiceTests.cs | 162 ++ .../IdentityTestHelpers.cs | 42 + .../LocationControllerSitesTests.cs | 3 +- .../LocationControllerTests.cs | 105 ++ .../LocationServiceTests.cs | 133 ++ .../SanitizedErrorsTests.cs | 71 + .../ServiceBehaviorTests.cs | 281 +++ .../ServiceRegistrationTests.cs | 75 + .../SettingsControllerTests.cs | 120 ++ .../SettingsServiceTests.cs | 168 ++ .../TaskListTemplateServiceTests.cs | 146 ++ .../UpliftControllerTests.cs | 213 +++ .../UserControllerTests.cs | 191 ++ .../UserServiceTests.cs | 195 ++ .../VendorControllerTests.cs | 256 ++- .../VendorOperationsControllerTests.cs | 38 +- .../VendorPortalControllerTests.cs | 317 ++++ .../VendorPortalDocumentTests.cs | 47 +- .../VendorServiceTests.cs | 12 +- .../WorkOrderControllerSearchTests.cs | 51 +- .../WorkOrderDataServiceBatchingTests.cs | 104 + .../WorkOrderRouteContractTests.cs | 206 ++ .../Api.SeaHavenIndustries.csproj | 4 + .../Controllers/AuthenticationController.cs | 160 +- .../Controllers/CalendarController.cs | 253 +-- .../Controllers/CommonController.cs | 109 +- .../Controllers/DashboardController.cs | 26 +- .../Controllers/DispatchController.cs | 310 +-- .../Controllers/DropdownOptionsController.cs | 101 +- .../Controllers/FollowUpController.cs | 232 +-- .../Controllers/LocationController.cs | 107 +- .../Controllers/QuotesController.cs | 264 +-- .../Controllers/SettingsController.cs | 169 +- .../Controllers/SyncController.cs | 404 +--- .../Controllers/TaskListTemplateController.cs | 125 +- .../Controllers/UpliftController.cs | 244 +-- .../Controllers/UserController.cs | 267 +-- .../Controllers/VendorController.cs | 180 +- .../Controllers/VendorOperationsController.cs | 630 +------ .../Controllers/VendorPortalController.cs | 850 ++------- .../Controllers/WorkOrderBoardController.cs | 216 +++ .../WorkOrderCompletionController.cs | 139 ++ .../Controllers/WorkOrderController.cs | 1680 ++--------------- .../Controllers/WorkOrderDetailController.cs | 75 + .../WorkOrderDispatchController.cs | 214 +++ .../Controllers/WorkOrderMediaController.cs | 85 + .../DTOs/DropdownOption_DTO.cs | 12 + .../Helper/SanitizedErrors.cs | 27 + .../Helper/SendGridEmailSender.cs | 20 + Api.SeaHavenIndustries/Helper/SendMessage.cs | 5 +- .../Helper/VendorDocumentScanWorker.cs | 5 +- .../Helper/VendorPortalTokenService.cs | 92 - .../Helper/ZipCodeDistance.cs | 4 +- .../DynamoSyncExternalSource.cs | 90 + .../Infrastructure/PortAdapters.cs | 108 ++ Api.SeaHavenIndustries/Program.cs | 19 +- BACKEND_ARCHITECTURE.md | 662 +------ .../Models/VendorPortalReadModels.cs | 101 + .../Dto/DispatchReadModels.cs | 169 ++ .../Dto/SettingsReadModels.cs | 8 + SeaHaven.DataServices/Dto/UserReadModels.cs | 21 + .../Implementation/CalendarDataService.cs | 85 + .../Implementation/CommentDataService.cs | 27 + .../Implementation/CommonDataService.cs | 81 + .../Implementation/DashboardDataService.cs | 34 + .../Implementation/DispatchDataService.cs | 587 +++++- .../DropdownOptionsDataService.cs | 71 + .../Implementation/FollowUpDataService.cs | 133 ++ .../ForgetPasswordDataService.cs | 83 + .../Implementation/LocationDataService.cs | 71 +- .../Implementation/QuotesDataService.cs | 104 + .../Implementation/SettingsDataService.cs | 125 ++ .../Implementation/SyncDataService.cs | 127 ++ .../TaskListTemplateDataService.cs | 53 + .../Implementation/UpliftDataService.cs | 138 ++ .../Implementation/UserDataService.cs | 118 ++ .../Implementation/VendorDataService.cs | 84 +- .../VendorDocumentDataService.cs | 43 + .../VendorOperationsDataService.cs | 231 +++ .../WorkOrderAuditDataService.cs | 43 + .../WorkOrderBoardMutationDataService.cs | 92 + .../WorkOrderCompletionDataService.cs | 30 + .../Implementation/WorkOrderDataService.cs | 201 +- .../WorkOrderFieldLockDataService.cs | 48 + .../WorkOrderIngestDataService.cs | 57 + .../WorkOrderMediaDataService.cs | 30 + .../WorkOrderOpsHealthDataService.cs | 32 + .../Interfaces/ICalendarDataService.cs | 14 + .../Interfaces/ICommentDataService.cs | 2 + .../Interfaces/ICommonDataService.cs | 13 + .../Interfaces/IDashboardDataService.cs | 15 + .../Interfaces/IDispatchDataService.cs | 24 + .../Interfaces/IDropdownOptionsDataService.cs | 15 + .../Interfaces/IFollowUpDataService.cs | 10 + .../Interfaces/IForgetPasswordDataService.cs | 13 + .../Interfaces/ILocationDataService.cs | 7 + .../Interfaces/IQuotesDataService.cs | 11 + .../Interfaces/ISettingsDataService.cs | 16 + .../Interfaces/ISyncDataService.cs | 25 + .../ITaskListTemplateDataService.cs | 8 + .../Interfaces/IUpliftDataService.cs | 17 + .../Interfaces/IUserDataService.cs | 10 + .../Interfaces/IVendorDataService.cs | 13 +- .../Interfaces/IVendorDocumentDataService.cs | 12 + .../IVendorOperationsDataService.cs | 33 + .../Interfaces/IWorkOrderAuditDataService.cs | 11 + .../IWorkOrderBoardMutationDataService.cs | 27 + .../IWorkOrderCompletionDataService.cs | 12 + .../Interfaces/IWorkOrderDataService.cs | 21 +- .../IWorkOrderFieldLockDataService.cs | 15 + .../Interfaces/IWorkOrderIngestDataService.cs | 15 + .../Interfaces/IWorkOrderMediaDataService.cs | 12 + .../IWorkOrderOpsHealthDataService.cs | 11 + SeaHaven.DataServices/Models/ReadModels.cs | 260 +++ .../Models/VendorOperationsReadModels.cs | 43 + .../Configuration/ServiceOptions.cs | 38 + SeaHaven.Services/DTOs/CalendarDTOs.cs | 50 + SeaHaven.Services/DTOs/CommonDTOs.cs | 48 + SeaHaven.Services/DTOs/DashboardDTOs.cs | 10 + SeaHaven.Services/DTOs/DispatchDTOs.cs | 154 ++ SeaHaven.Services/DTOs/DispatchServiceDTOs.cs | 131 ++ SeaHaven.Services/DTOs/DropdownOptionDTOs.cs | 12 + SeaHaven.Services/DTOs/FollowUpDTOs.cs | 65 + SeaHaven.Services/DTOs/IdentityDTOs.cs | 68 + SeaHaven.Services/DTOs/LocationDTOs.cs | 26 + SeaHaven.Services/DTOs/QuoteDTOs.cs | 108 ++ SeaHaven.Services/DTOs/SettingsDTOs.cs | 98 + SeaHaven.Services/DTOs/SyncDTOs.cs | 28 + .../DTOs/TaskListTemplateDTOs.cs | 32 + SeaHaven.Services/DTOs/UpliftDTOs.cs | 66 + SeaHaven.Services/DTOs/VendorDocumentDTOs.cs | 36 + .../DTOs/VendorOperationsRequests.cs | 36 + .../DTOs/VendorOperationsResults.cs | 155 ++ SeaHaven.Services/DTOs/VendorPortalDTOs.cs | 65 + .../DTOs/VendorPortalServiceDTOs.cs | 164 ++ .../DTOs/WorkOrderServiceDTOs.cs | 115 ++ .../DependencyInjection/ServicesModule.cs | 10 +- .../Implementation/AuthenticationService.cs | 160 ++ .../Implementation/CalendarService.cs | 125 ++ .../Implementation/CommonService.cs | 85 + .../Implementation/DashboardService.cs | 29 + .../Implementation/DispatchService.cs | 179 ++ .../Implementation/DropdownOptionsService.cs | 88 + .../Implementation/FollowUpService.cs | 127 +- .../Implementation/LocationService.cs | 64 + .../Implementation/QuotesService.cs | 191 ++ .../Implementation/SettingsService.cs | 166 ++ .../Implementation/SyncService.cs | 304 +++ .../Implementation/TaskListTemplateService.cs | 92 + .../Implementation/UpliftService.cs | 178 ++ .../Implementation/UserService.cs | 201 ++ .../Implementation/VendorOperationsService.cs | 469 +++++ .../Implementation/VendorPortalService.cs | 703 +++++++ .../VendorPortalTokenService.cs | 93 + .../Implementation/VendorService.cs | 142 ++ .../Implementation/WorkOrderAuditService.cs | 27 +- .../WorkOrderBoardCancelService.cs | 14 +- .../WorkOrderBoardCreateService.cs | 45 +- .../WorkOrderBoardUpdateService.cs | 207 +- .../WorkOrderCompletionService.cs | 16 +- .../WorkOrderDispatchService.cs | 433 +++++ .../WorkOrderFieldLockService.cs | 46 +- .../Implementation/WorkOrderIngestService.cs | 42 +- .../Implementation/WorkOrderMediaService.cs | 24 +- .../WorkOrderOpsHealthService.cs | 35 +- .../Implementation/WorkOrderService.cs | 499 ++++- .../Interfaces/IAuthenticationService.cs | 14 + .../Interfaces/ICalendarService.cs | 14 + .../Interfaces/ICommonService.cs | 13 + .../Interfaces/IDashboardService.cs | 9 + .../Interfaces/IDispatchService.cs | 3 + .../Interfaces/IDropdownOptionsService.cs | 13 + SeaHaven.Services/Interfaces/IEmailSender.cs | 11 + .../Interfaces/IFollowUpService.cs | 7 + .../Interfaces/ILocationService.cs | 6 + .../Interfaces/IQuotesService.cs | 16 + SeaHaven.Services/Interfaces/IServicePorts.cs | 44 + .../Interfaces/ISettingsService.cs | 15 + .../Interfaces/ISyncExternalSource.cs | 9 + SeaHaven.Services/Interfaces/ISyncService.cs | 15 + .../Interfaces/ITaskListTemplateService.cs | 13 + .../Interfaces/IUpliftService.cs | 14 + SeaHaven.Services/Interfaces/IUserService.cs | 14 + .../Interfaces/IVendorOperationsService.cs | 18 + .../Interfaces/IVendorPortalService.cs | 21 + .../Interfaces/IVendorPortalTokenService.cs | 12 + .../Interfaces/IVendorService.cs | 5 + .../Interfaces/IWorkOrderDispatchService.cs | 18 + .../Interfaces/IWorkOrderService.cs | 26 + .../Interfaces/IZipCodeDistance.cs | 7 + SeaHaven.Services/SeaHaven.Services.csproj | 4 + .../SyncFieldMergePolicyTests.cs | 25 +- .../WorkOrderAuditServiceTests.cs | 17 +- .../WorkOrderBoardCancelServiceTests.cs | 9 +- .../WorkOrderBoardConcurrencyTests.cs | 7 +- .../WorkOrderBoardCreateRelationalTests.cs | 14 +- .../WorkOrderBoardCreateServiceTests.cs | 7 +- .../WorkOrderBoardCreateSyncLockTests.cs | 14 +- .../WorkOrderBoardUpdateServiceTests.cs | 11 +- .../WorkOrderFieldLockServiceTests.cs | 7 +- .../WorkOrderPhase6Tests.cs | 21 +- .../WorkOrderPhase7CoexistenceTests.cs | 33 +- .../WorkOrderWeekRolledTests.cs | 2 +- 215 files changed, 16658 insertions(+), 6423 deletions(-) create mode 100644 .github/workflows/architecture-quality.yml create mode 100644 Api.SeaHavenIndustries.Tests/ArchitectureTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs create mode 100644 Api.SeaHavenIndustries.Tests/LocationControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/LocationServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/SanitizedErrorsTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/UserControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/UserServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderDispatchController.cs create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs create mode 100644 Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs create mode 100644 Api.SeaHavenIndustries/Helper/SanitizedErrors.cs create mode 100644 Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs delete mode 100644 Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs create mode 100644 Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs create mode 100644 Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs create mode 100644 Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs create mode 100644 SeaHaven.DataServices/Dto/DispatchReadModels.cs create mode 100644 SeaHaven.DataServices/Dto/SettingsReadModels.cs create mode 100644 SeaHaven.DataServices/Dto/UserReadModels.cs create mode 100644 SeaHaven.DataServices/Implementation/CalendarDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/CommonDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/DashboardDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/DropdownOptionsDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/SettingsDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/SyncDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/UpliftDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderOpsHealthDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/ICalendarDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/ICommonDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IDashboardDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IDropdownOptionsDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/ISettingsDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/ISyncDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IUpliftDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderOpsHealthDataService.cs create mode 100644 SeaHaven.DataServices/Models/ReadModels.cs create mode 100644 SeaHaven.DataServices/Models/VendorOperationsReadModels.cs create mode 100644 SeaHaven.Services/Configuration/ServiceOptions.cs create mode 100644 SeaHaven.Services/DTOs/CalendarDTOs.cs create mode 100644 SeaHaven.Services/DTOs/CommonDTOs.cs create mode 100644 SeaHaven.Services/DTOs/DashboardDTOs.cs create mode 100644 SeaHaven.Services/DTOs/DispatchServiceDTOs.cs create mode 100644 SeaHaven.Services/DTOs/DropdownOptionDTOs.cs create mode 100644 SeaHaven.Services/DTOs/IdentityDTOs.cs create mode 100644 SeaHaven.Services/DTOs/QuoteDTOs.cs create mode 100644 SeaHaven.Services/DTOs/SettingsDTOs.cs create mode 100644 SeaHaven.Services/DTOs/SyncDTOs.cs create mode 100644 SeaHaven.Services/DTOs/TaskListTemplateDTOs.cs create mode 100644 SeaHaven.Services/DTOs/UpliftDTOs.cs create mode 100644 SeaHaven.Services/DTOs/VendorDocumentDTOs.cs create mode 100644 SeaHaven.Services/DTOs/VendorOperationsRequests.cs create mode 100644 SeaHaven.Services/DTOs/VendorOperationsResults.cs create mode 100644 SeaHaven.Services/DTOs/VendorPortalDTOs.cs create mode 100644 SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs create mode 100644 SeaHaven.Services/Implementation/AuthenticationService.cs create mode 100644 SeaHaven.Services/Implementation/CalendarService.cs create mode 100644 SeaHaven.Services/Implementation/CommonService.cs create mode 100644 SeaHaven.Services/Implementation/DashboardService.cs create mode 100644 SeaHaven.Services/Implementation/DropdownOptionsService.cs create mode 100644 SeaHaven.Services/Implementation/QuotesService.cs create mode 100644 SeaHaven.Services/Implementation/SettingsService.cs create mode 100644 SeaHaven.Services/Implementation/SyncService.cs create mode 100644 SeaHaven.Services/Implementation/TaskListTemplateService.cs create mode 100644 SeaHaven.Services/Implementation/UpliftService.cs create mode 100644 SeaHaven.Services/Implementation/UserService.cs create mode 100644 SeaHaven.Services/Implementation/VendorOperationsService.cs create mode 100644 SeaHaven.Services/Implementation/VendorPortalService.cs create mode 100644 SeaHaven.Services/Implementation/VendorPortalTokenService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderDispatchService.cs create mode 100644 SeaHaven.Services/Interfaces/IAuthenticationService.cs create mode 100644 SeaHaven.Services/Interfaces/ICalendarService.cs create mode 100644 SeaHaven.Services/Interfaces/ICommonService.cs create mode 100644 SeaHaven.Services/Interfaces/IDashboardService.cs create mode 100644 SeaHaven.Services/Interfaces/IDropdownOptionsService.cs create mode 100644 SeaHaven.Services/Interfaces/IEmailSender.cs create mode 100644 SeaHaven.Services/Interfaces/IQuotesService.cs create mode 100644 SeaHaven.Services/Interfaces/IServicePorts.cs create mode 100644 SeaHaven.Services/Interfaces/ISettingsService.cs create mode 100644 SeaHaven.Services/Interfaces/ISyncExternalSource.cs create mode 100644 SeaHaven.Services/Interfaces/ISyncService.cs create mode 100644 SeaHaven.Services/Interfaces/ITaskListTemplateService.cs create mode 100644 SeaHaven.Services/Interfaces/IUpliftService.cs create mode 100644 SeaHaven.Services/Interfaces/IUserService.cs create mode 100644 SeaHaven.Services/Interfaces/IVendorOperationsService.cs create mode 100644 SeaHaven.Services/Interfaces/IVendorPortalService.cs create mode 100644 SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs create mode 100644 SeaHaven.Services/Interfaces/IZipCodeDistance.cs diff --git a/.github/workflows/architecture-quality.yml b/.github/workflows/architecture-quality.yml new file mode 100644 index 0000000..ada851d --- /dev/null +++ b/.github/workflows/architecture-quality.yml @@ -0,0 +1,51 @@ +name: Architecture and changed-file quality + +on: + pull_request: + branches: [main, dev] + +permissions: + contents: read + +jobs: + architecture: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Restore + run: dotnet restore SeaHavenIndustries.sln + + - name: Verify architecture boundaries + run: >- + dotnet test + Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj + --no-restore + --filter FullyQualifiedName~ArchitectureTests + + - name: Verify formatting of changed C# files + shell: bash + run: | + mapfile -t files < <( + git diff --name-only --diff-filter=ACMR \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}" \ + -- '*.cs' + ) + + if (( ${#files[@]} == 0 )); then + exit 0 + fi + + dotnet format SeaHavenIndustries.sln \ + --no-restore \ + --verify-no-changes \ + --include "${files[@]}" diff --git a/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs b/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs new file mode 100644 index 0000000..b636e67 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs @@ -0,0 +1,76 @@ +using System.Reflection; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class ArchitectureTests +{ + private static readonly Assembly ApiAssembly = typeof(VendorPortalController).Assembly; + private static readonly Assembly ServicesAssembly = typeof(VendorService).Assembly; + private static readonly Assembly DataServicesAssembly = typeof(VendorDataService).Assembly; + + private const string ServiceImplementationNamespace = "SeaHaven.Services.Implementation"; + private const string DataServiceImplementationNamespace = "SeaHaven.DataServices.Implementation"; + + private static IEnumerable ApiControllers => + ApiAssembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && typeof(ControllerBase).IsAssignableFrom(t)); + + private static IEnumerable BusinessServiceImplementations => + ServicesAssembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType) + .Where(t => t.GetInterfaces().Any(i => i.Name == "I" + t.Name)); + + private static bool IsConcrete(Type type) => type.IsClass && !type.IsAbstract && !type.IsInterface; + + private static bool IsDbContext(Type type) => typeof(DbContext).IsAssignableFrom(type); + + private static bool IsConcreteServiceImplementation(Type type) => + type.Namespace == ServiceImplementationNamespace && IsConcrete(type); + + private static bool IsConcreteDataService(Type type) => + type.Namespace == DataServiceImplementationNamespace && IsConcrete(type); + + private static IEnumerable ParametersOf(IEnumerable types) => + types.SelectMany(t => t.GetConstructors().SelectMany(c => c.GetParameters())); + + [Fact] + public void Controllers_DoNotInject_DbContext_ConcreteDataServices_Or_ConcreteServiceImplementations() + { + ApiControllers.Should().NotBeEmpty("the API must expose controllers for this invariant to be meaningful"); + + var violations = ParametersOf(ApiControllers) + .Where(p => IsDbContext(p.ParameterType) + || IsConcreteDataService(p.ParameterType) + || IsConcreteServiceImplementation(p.ParameterType)) + .Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})") + .ToList(); + + violations.Should().BeEmpty( + "controllers must depend only on interfaces/abstractions, never on DbContext, concrete data services, or concrete service implementations"); + } + + [Fact] + public void BusinessServices_DoNotInject_DbContext_Or_IConfiguration() + { + BusinessServiceImplementations.Should().NotBeEmpty( + "business services must be discovered for this invariant to be meaningful"); + + var violations = ParametersOf(BusinessServiceImplementations) + .Where(p => IsDbContext(p.ParameterType) + || typeof(IConfiguration).IsAssignableFrom(p.ParameterType)) + .Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})") + .ToList(); + + violations.Should().BeEmpty( + "business services must depend on data-service interfaces and typed options, never on DbContext or IConfiguration"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs new file mode 100644 index 0000000..99d5ab8 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -0,0 +1,261 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using System.Text.Json; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class AuthenticationControllerTests +{ + private static string Json(object? value) => + JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object)); + + private static AuthenticationController NewController(Mock service, string? userId = null) + { + var controller = new AuthenticationController(service.Object, Mock.Of>()); + if (userId != null) + { + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "Test")) + } + }; + } + return controller; + } + + [Fact] + public async Task Login_WithSuccessfulResult_MapsLowercaseTokenPayload() + { + var service = new Mock(); + service.Setup(s => s.LoginAsync("alice", "pw", It.IsAny())) + .ReturnsAsync(new LoginResultDTO + { + Token = "tok", + Expiration = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Email = "alice@example.com", + UserRole = "Admin", + PhoneNumber = "555", + Fullname = "Alice Q", + Id = "user-1" + }); + + var controller = NewController(service); + + var result = await controller.Login(new LoginModel { Username = "alice", Password = "pw" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var json = Json(ok.Value); + json.Should().Contain("\"token\":\"tok\""); + json.Should().Contain("\"expiration\":\""); + json.Should().Contain("\"email\":\"alice@example.com\""); + json.Should().Contain("\"userRoles\":\"Admin\""); + json.Should().Contain("\"phoneNumber\":\"555\""); + json.Should().Contain("\"fullname\":\"Alice Q\""); + json.Should().Contain("\"id\":\"user-1\""); + } + + [Fact] + public async Task Login_WithNullResult_ReturnsUnauthorized() + { + var service = new Mock(); + service.Setup(s => s.LoginAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((LoginResultDTO?)null); + + var controller = NewController(service); + + var result = await controller.Login(new LoginModel(), CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Login_WhenServiceThrows_ReturnsSanitized500AndLogsInternally() + { + var service = new Mock(); + service.Setup(s => s.LoginAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail")); + + var logger = new Mock>(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var controller = new AuthenticationController(service.Object, logger.Object); + + var result = await controller.Login(new LoginModel { Username = "x", Password = "y" }, CancellationToken.None); + + var status = result.Should().BeOfType().Subject; + status.StatusCode.Should().Be(500); + var message = (string)status.Value!; + message.Should().NotContain("SECRET"); + message.Should().NotContain("internal-stack-detail"); + message.Should().Contain("reference"); + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.Is(ex => ex.Message.Contains("SECRET")), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ChangePassword_Success_ReturnsSuccessMessageWithTrailingSpace() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny())) + .ReturnsAsync(true); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success "); + response.Message.Should().Be("Password successfully changed"); + } + + [Fact] + public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword(new ChangePasswords(), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Old Password is incorrect"); + } + + [Fact] + public async Task UpdateProfile_MapsProfileDataIntoDataResponse() + { + var service = new Mock(); + service.Setup(s => s.UpdateProfileAsync("42", It.IsAny(), It.IsAny())) + .ReturnsAsync(new UserProfileDTO { FirstName = "Bob", Email = "bob@x.com", Contact = "111" }); + + var controller = NewController(service, "42"); + + var form = new User_DTO { Name = "Bob", Email = "bob@x.com", Contact = "111" }; + var result = await controller.UserProfileUpdate(form, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Introduction Updated Successfully"); + var json = Json((object?)response.Data); + json.Should().Contain("\"FirstName\":\"Bob\""); + json.Should().Contain("\"Email\":\"bob@x.com\""); + json.Should().Contain("\"Contact\":\"111\""); + } + + [Fact] + public async Task ForgetPassword_Found_ReturnsCheckEmailMessage() + { + var service = new Mock(); + service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny())).ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.ForgetPassword("a@b.com", CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success "); + response.Message.Should().Be("Please check your email for code"); + } + + [Fact] + public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage() + { + var service = new Mock(); + service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.ForgetPassword("x@y.com", CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Error"); + response.Message.Should().Be("No such email is registered"); + } + + [Theory] + [InlineData(true, "Success ", "Code Matched")] + [InlineData(false, "Error", "Code Not Matched")] + public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage) + { + var service = new Mock(); + service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny())).ReturnsAsync(matched); + + var controller = NewController(service); + + var result = await controller.VerificationCode("123456", CancellationToken.None); + + if (matched) + { + var ok = result.Should().BeOfType().Subject; + var r = ok.Value.Should().BeOfType().Subject; + r.Status.Should().Be(expectedStatus); + r.Message.Should().Be(expectedMessage); + } + else + { + var bad = result.Should().BeOfType().Subject; + var r = bad.Value.Should().BeOfType().Subject; + r.Status.Should().Be(expectedStatus); + r.Message.Should().Be(expectedMessage); + } + } + + [Fact] + public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage() + { + var service = new Mock(); + service.Setup(s => s.ResetPasswordAsync("a@b.com", "123", "new", It.IsAny())).ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com", Code = "123", Password = "new" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success "); + response.Message.Should().Be("password changed"); + } + + [Fact] + public async Task ResetPassword_NotMatched_ReturnsEmailOrCodeNotFoundMessage() + { + var service = new Mock(); + service.Setup(s => s.ResetPasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Your email or code not found please check"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs new file mode 100644 index 0000000..7d9aa08 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -0,0 +1,171 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class AuthenticationServiceTests +{ + private static AuthenticationService NewService( + Mock userData, + Mock forget, + Mock email, + out Mock> store, + out Mock> hasher) + { + var (manager, s, h) = IdentityTestHelpers.CreateUserManager(); + store = s; + hasher = h; + return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object); + } + + private static JwtOptions JwtOptions => new() + { + Secret = new string('x', 64), + ValidIssuer = "issuer", + ValidAudience = "audience" + }; + + [Fact] + public async Task Login_UnknownUser_ReturnsNull() + { + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out _); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var result = await service.LoginAsync("nobody", "pw", CancellationToken.None); + + result.Should().BeNull(); + } + + [Fact] + public async Task Login_DeletedUser_RejectedBeforePasswordCheck() + { + var deletedUser = IdentityTestHelpers.User(isDeleted: true); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(deletedUser); + store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny())).ReturnsAsync(new List()); + + var result = await service.LoginAsync("alice", "pw", CancellationToken.None); + + result.Should().BeNull(); + hasher.Verify(h => h.VerifyHashedPassword(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity() + { + var user = IdentityTestHelpers.User(); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + store.Setup(s => s.GetRolesAsync(user, It.IsAny())).ReturnsAsync(new List { "Admin", "Manager" }); + store.As>() + .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).ReturnsAsync("hash"); + hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "pw")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Success); + + var result = await service.LoginAsync("alice", "pw", CancellationToken.None); + + result.Should().NotBeNull(); + result!.Id.Should().Be(user.Id); + result.Email.Should().Be(user.Email); + result.PhoneNumber.Should().Be(user.PhoneNumber); + result.Fullname.Should().Be("Alice Q"); + result.UserRole.Should().Be("Admin"); + result.Token.Should().NotBeNullOrEmpty(); + result.Expiration.Should().BeAfter(DateTime.Now.AddDays(9)); + } + + [Fact] + public async Task Login_BadPassword_ReturnsNull() + { + var user = IdentityTestHelpers.User(); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + store.As>() + .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).ReturnsAsync("hash"); + hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "wrong")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Failed); + + var result = await service.LoginAsync("alice", "wrong", CancellationToken.None); + + result.Should().BeNull(); + } + + [Fact] + public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(user); + var forget = new Mock(); + var email = new Mock(); + + var service = NewService(userData, forget, email, out _, out _); + + var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + + found.Should().BeTrue(); + forget.Verify( + f => f.ReplaceCodeAsync( + user.Email!, + user.Id, + It.Is(code => code.Length == 6 && code.All(char.IsDigit)), + It.IsAny()), + Times.Once); + email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is(b => b.Contains("Your Password Reset Code is:"))), Times.Once); + } + + [Fact] + public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); + var forget = new Mock(); + var email = new Mock(); + + var service = NewService(userData, forget, email, out _, out _); + + var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None); + + found.Should().BeFalse(); + forget.Verify(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task VerifyCode_ForwardsDataServiceResult(bool exists) + { + var forget = new Mock(); + forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny())).ReturnsAsync(exists); + + var service = NewService(new Mock(), forget, new Mock(), out _, out _); + + var result = await service.VerifyCodeAsync("abc", CancellationToken.None); + + result.Should().Be(exists); + } + + [Fact] + public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset() + { + var forget = new Mock(); + forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var service = NewService(new Mock(), forget, new Mock(), out var store, out _); + + var result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None); + + result.Should().BeFalse(); + store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs b/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs index 571b606..8c2299f 100644 --- a/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs @@ -3,34 +3,17 @@ using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentAssertions; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class CalendarControllerTests { - private static ApplicationDbContext NewContext() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - return new ApplicationDbContext(options); - } - - private static UserManager MockUserManager() - { - var store = new Mock>(); - return new Mock>( - store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object; - } - - private static CalendarController NewController(ApplicationDbContext ctx) => - new(MockUserManager(), ctx); - private static EditEvent_DTO ValidDto(int? id = null, string title = "Standup") => new() { Id = id, @@ -43,164 +26,181 @@ public class CalendarControllerTests EndTime = "08:30 AM" }; - private static Events SeedEvent(ApplicationDbContext ctx, string title, DateTime startDate, bool? isDeleted = false) - { - var ev = new Events - { - Title = title, - EventColor = "primary", - ReminderMinutes = 15, - StartDate = startDate, - EndDate = startDate, - IsDeleted = isDeleted, - CreatedDate = DateTime.Now - }; - ctx.Events.Add(ev); - ctx.SaveChanges(); - return ev; - } + private static CalendarController NewController(Mock service) => + new(service.Object, Mock.Of>()); private static object Prop(object source, string name) => source.GetType().GetProperty(name)!.GetValue(source)!; [Fact] - public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk() + public async Task AddEvent_WhenServiceSucceeds_ReturnsOkWithCreatedMessage() { - using var ctx = NewContext(); - var result = await NewController(ctx).AddEvent(ValidDto()); + var service = new Mock(); + var result = await NewController(service).AddEvent(ValidDto(), CancellationToken.None); var ok = result.Should().BeOfType().Subject; - ok.Value.Should().BeOfType().Which.Status.Should().Be("200"); - - var saved = ctx.Events.Single(); - saved.Title.Should().Be("Standup"); - saved.IsDeleted.Should().Be(false); - saved.CreatedDate.Should().NotBeNull(); + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Event Created Successfully"); + service.Verify(s => s.CreateEventAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] - public async Task EditEvent_WhenIdMissing_ReturnsBadRequest() + public async Task AddEvent_WhenServiceThrows_ReturnsSanitizedMessageAndLogsInternally() { - using var ctx = NewContext(); - var result = await NewController(ctx).EditEvent(ValidDto(id: null)); + var service = new Mock(); + service.Setup(s => s.CreateEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SECRET-internal-db-connection-leak")); - result.Should().BeOfType(); + var logger = new Mock>(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var controller = new CalendarController(service.Object, logger.Object); + + var result = await controller.AddEvent(ValidDto(), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Error"); + response.Message.Should().NotContain("SECRET"); + response.Message.Should().NotContain("internal-db-connection-leak"); + response.Message.Should().Contain("reference"); + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.Is(ex => ex.Message.Contains("SECRET")), + It.IsAny>()), + Times.Once); } [Fact] - public async Task EditEvent_WhenEventNotFound_ReturnsNotFound() + public async Task EditEvent_WhenIdMissing_ReturnsBadRequestWithoutCallingService() { - using var ctx = NewContext(); - var result = await NewController(ctx).EditEvent(ValidDto(id: 999)); + var service = new Mock(); + var result = await NewController(service).EditEvent(ValidDto(id: null), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Event ID is required for editing"); + service.Verify(s => s.UpdateEventAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EditEvent_WhenServiceReturnsFalse_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.UpdateEventAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).EditEvent(ValidDto(id: 5), CancellationToken.None); + + var nf = result.Should().BeOfType().Subject; + var response = nf.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Event not found"); + } + + [Fact] + public async Task EditEvent_WhenServiceReturnsTrue_ReturnsOkWithUpdatedMessage() + { + var service = new Mock(); + service.Setup(s => s.UpdateEventAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await NewController(service).EditEvent(ValidDto(id: 5, title: "New"), CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType().Which.Message.Should().Be("Event Updated Successfully"); + } + + [Fact] + public async Task GetEventById_WhenServiceReturnsNull_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.GetEventByIdAsync(7, It.IsAny())).ReturnsAsync((EventDTO?)null); + + var result = await NewController(service).GetEventById(7, CancellationToken.None); result.Should().BeOfType(); } [Fact] - public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified() + public async Task GetEventById_WhenServiceReturnsDto_ReturnsOkWithEventDto() { - using var ctx = NewContext(); - var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1)); + var service = new Mock(); + service.Setup(s => s.GetEventByIdAsync(7, It.IsAny())) + .ReturnsAsync(new EventDTO { Id = 7, Title = "Review", createdby = "abc" }); - var result = await NewController(ctx).EditEvent(ValidDto(id: existing.Id, title: "New")); - - result.Should().BeOfType(); - var updated = ctx.Events.Single(); - updated.Title.Should().Be("New"); - updated.LastModificationTime.Should().NotBeNull(); - } - - [Fact] - public async Task GetEventById_WhenExists_ReturnsEventDto() - { - using var ctx = NewContext(); - var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1)); - - var result = await NewController(ctx).GetEventById(existing.Id); + var result = await NewController(service).GetEventById(7, CancellationToken.None); var ok = result.Should().BeOfType().Subject; - var dto = ok.Value.Should().BeOfType().Subject.Data as Event_DTO; + var response = ok.Value.Should().BeOfType().Subject; + var dto = response.Data as Event_DTO; + dto.Should().NotBeNull(); dto!.Title.Should().Be("Review"); + dto!.createdby.Should().Be("abc"); } [Fact] - public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound() + public async Task DeleteEvent_WhenServiceReturnsFalse_ReturnsNotFound() { - using var ctx = NewContext(); - var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true); + var service = new Mock(); + service.Setup(s => s.DeleteEventAsync(9, It.IsAny())).ReturnsAsync(false); - var result = await NewController(ctx).GetEventById(deleted.Id); + var result = await NewController(service).DeleteEvent(9, CancellationToken.None); result.Should().BeOfType(); } [Fact] - public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow() + public async Task DeleteEvent_WhenServiceReturnsTrue_ReturnsOkWithDeletedMessage() { - using var ctx = NewContext(); - var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1)); + var service = new Mock(); + service.Setup(s => s.DeleteEventAsync(9, It.IsAny())).ReturnsAsync(true); - var result = await NewController(ctx).DeleteEvent(existing.Id); + var result = await NewController(service).DeleteEvent(9, CancellationToken.None); - result.Should().BeOfType(); - var row = ctx.Events.Single(); - row.IsDeleted.Should().Be(true); - row.DeletionTime.Should().NotBeNull(); + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType().Which.Message.Should().Be("Event Deleted Successfully"); } [Fact] - public async Task DeleteEvent_WhenNotFound_ReturnsNotFound() + public async Task GetEventList_ReturnsOkWithRowsAndTotalCount() { - using var ctx = NewContext(); - var result = await NewController(ctx).DeleteEvent(12345); + var service = new Mock(); + service.Setup(s => s.GetEventListAsync("alpha", 1, 10, It.IsAny())) + .ReturnsAsync(new PagedEventsResult + { + TotalCount = 2, + Rows = new List + { + new() { Id = 1, Title = "Alpha meeting" }, + new() { Id = 2, Title = "Alpha review" } + } + }); - result.Should().BeOfType(); - } - - [Fact] - public void GetEventList_ExcludesDeletedAndCountsAfterFilter() - { - using var ctx = NewContext(); - SeedEvent(ctx, "Alpha meeting", new DateTime(2026, 6, 1)); - SeedEvent(ctx, "Beta meeting", new DateTime(2026, 6, 2)); - SeedEvent(ctx, "Alpha review", new DateTime(2026, 6, 3)); - SeedEvent(ctx, "Alpha archived", new DateTime(2026, 6, 4), isDeleted: true); - - var result = NewController(ctx).GetEventList(search: "alpha", page: 1, pageSize: 10); + var result = await NewController(service).GetEventList(search: "alpha", page: 1, pageSize: 10); var ok = result.Should().BeOfType().Subject; var data = ok.Value.Should().BeOfType().Subject.Data!; ((int)Prop(data, "totalCount")).Should().Be(2); - } - - [Fact] - public void GetEventList_OrdersByStartDateDescending() - { - using var ctx = NewContext(); - SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1)); - SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10)); - SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5)); - - var result = NewController(ctx).GetEventList(page: 1, pageSize: 10); - - var data = (result as OkObjectResult)!.Value.As().Data!; var rows = ((IEnumerable)Prop(data, "rows")).Cast().ToList(); - rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest"); + rows.Should().HaveCount(2); } [Fact] - public void GetEventsForCalendar_ReturnsOnlyEventsOverlappingRange() + public async Task GetEventsForCalendar_ReturnsOkWithEvents() { - using var ctx = NewContext(); - SeedEvent(ctx, "InRange", new DateTime(2026, 6, 15)); - SeedEvent(ctx, "BeforeRange", new DateTime(2026, 5, 1)); - SeedEvent(ctx, "AfterRange", new DateTime(2026, 7, 1)); + var service = new Mock(); + service.Setup(s => s.GetEventsForCalendarAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = 1, Title = "InRange" } }); - var result = NewController(ctx).GetEventsForCalendar( - start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30)); + var result = await NewController(service).GetEventsForCalendar( + start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30), CancellationToken.None); var ok = result.Should().BeOfType().Subject; - var events = ok.Value.As().Data as IEnumerable; - events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" }); + var data = ok.Value.Should().BeOfType().Subject.Data; + var events = ((IEnumerable)data!).Cast().ToList(); + events.Single().Title.Should().Be("InRange"); } } diff --git a/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs new file mode 100644 index 0000000..bceb520 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs @@ -0,0 +1,188 @@ +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class CalendarServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static CalendarService NewService(ApplicationDbContext ctx) => + new(new CalendarDataService(ctx)); + + private static EditEventInput ValidInput(int? id = null, string title = "Standup") => new() + { + Id = id, + Title = title, + ReminderMinutes = 15, + EventColor = "primary", + StartDate = new DateTime(2026, 6, 1), + StartTime = "08:00 AM", + EndDate = new DateTime(2026, 6, 1), + EndTime = "08:30 AM" + }; + + private static Events SeedEvent(ApplicationDbContext ctx, string title, DateTime startDate, bool? isDeleted = false) + { + var ev = new Events + { + Title = title, + EventColor = "primary", + ReminderMinutes = 15, + StartDate = startDate, + EndDate = startDate, + IsDeleted = isDeleted, + CreatedDate = DateTime.Now + }; + ctx.Events.Add(ev); + ctx.SaveChanges(); + return ev; + } + + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + [Fact] + public async Task CreateEventAsync_PersistsEventWithMappedFieldsAndCreatedDate() + { + using var ctx = NewContext(); + await NewService(ctx).CreateEventAsync(ValidInput(), CancellationToken.None); + + var saved = ctx.Events.Single(); + saved.Title.Should().Be("Standup"); + saved.IsDeleted.Should().Be(false); + saved.CreatedDate.Should().NotBeNull(); + } + + [Fact] + public async Task UpdateEventAsync_WhenNotFound_ReturnsFalse() + { + using var ctx = NewContext(); + var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: 999), CancellationToken.None); + result.Should().BeFalse(); + } + + [Fact] + public async Task UpdateEventAsync_WhenSoftDeleted_ReturnsFalse() + { + using var ctx = NewContext(); + var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1), isDeleted: true); + + var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: existing.Id, title: "New"), CancellationToken.None); + + result.Should().BeFalse(); + } + + [Fact] + public async Task UpdateEventAsync_WhenEventExists_UpdatesFieldsAndStampsModified() + { + using var ctx = NewContext(); + var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1)); + + var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: existing.Id, title: "New"), CancellationToken.None); + + result.Should().BeTrue(); + var updated = ctx.Events.Single(); + updated.Title.Should().Be("New"); + updated.LastModificationTime.Should().NotBeNull(); + } + + [Fact] + public async Task GetEventByIdAsync_WhenExists_ReturnsMappedDto() + { + using var ctx = NewContext(); + var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1)); + + var dto = await NewService(ctx).GetEventByIdAsync(existing.Id, CancellationToken.None); + + dto.Should().NotBeNull(); + dto!.Title.Should().Be("Review"); + } + + [Fact] + public async Task GetEventByIdAsync_WhenSoftDeleted_ReturnsNull() + { + using var ctx = NewContext(); + var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true); + + var dto = await NewService(ctx).GetEventByIdAsync(deleted.Id, CancellationToken.None); + + dto.Should().BeNull(); + } + + [Fact] + public async Task DeleteEventAsync_WhenExists_SoftDeletesAndKeepsRow() + { + using var ctx = NewContext(); + var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1)); + + var result = await NewService(ctx).DeleteEventAsync(existing.Id, CancellationToken.None); + + result.Should().BeTrue(); + var row = ctx.Events.Single(); + row.IsDeleted.Should().Be(true); + row.DeletionTime.Should().NotBeNull(); + } + + [Fact] + public async Task DeleteEventAsync_WhenNotFound_ReturnsFalse() + { + using var ctx = NewContext(); + var result = await NewService(ctx).DeleteEventAsync(12345, CancellationToken.None); + result.Should().BeFalse(); + } + + [Fact] + public async Task GetEventListAsync_ExcludesDeletedAndCountsAfterFilter() + { + using var ctx = NewContext(); + SeedEvent(ctx, "Alpha meeting", new DateTime(2026, 6, 1)); + SeedEvent(ctx, "Beta meeting", new DateTime(2026, 6, 2)); + SeedEvent(ctx, "Alpha review", new DateTime(2026, 6, 3)); + SeedEvent(ctx, "Alpha archived", new DateTime(2026, 6, 4), isDeleted: true); + + var result = await NewService(ctx).GetEventListAsync("alpha", 1, 10, CancellationToken.None); + + result.TotalCount.Should().Be(2); + result.Rows.Should().HaveCount(2); + } + + [Fact] + public async Task GetEventListAsync_OrdersByStartDateDescending() + { + using var ctx = NewContext(); + SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1)); + SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10)); + SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5)); + + var result = await NewService(ctx).GetEventListAsync(null, 1, 10, CancellationToken.None); + + result.Rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest"); + } + + [Fact] + public async Task GetEventsForCalendarAsync_ReturnsOnlyEventsOverlappingRange() + { + using var ctx = NewContext(); + SeedEvent(ctx, "InRange", new DateTime(2026, 6, 15)); + SeedEvent(ctx, "BeforeRange", new DateTime(2026, 5, 1)); + SeedEvent(ctx, "AfterRange", new DateTime(2026, 7, 1)); + + var events = await NewService(ctx).GetEventsForCalendarAsync( + new DateTime(2026, 6, 1), new DateTime(2026, 6, 30), CancellationToken.None); + + events.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" }); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs new file mode 100644 index 0000000..0af8f00 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -0,0 +1,49 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DashboardServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static DashboardService NewService(ApplicationDbContext ctx) => + new(new DashboardDataService(ctx)); + + private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) => + new() { Status = status, istemplate = isTemplate, AssignTo = assignTo }; + + [Fact] + public async Task GetStatsAsync_CountsByStatusExcludingTemplates() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + Wo("Open", isTemplate: false), + Wo("Open", isTemplate: false, assignTo: "u1"), + Wo("In Progress"), + Wo("On Hold"), + Wo("Done"), + Wo("Done"), + Wo("Open", isTemplate: true), + Wo("Cancelled", isTemplate: false) + ); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None); + + stats.Total.Should().Be(7); + stats.Open.Should().Be(4); + stats.NotDispatched.Should().Be(1); + stats.Completed.Should().Be(2); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs new file mode 100644 index 0000000..bf994ec --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs @@ -0,0 +1,223 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DispatchBatchingBehaviorTests +{ + private static WorkOrderDispatchService BuildService( + Mock? woData = null, + Mock? vendorData = null, + Mock? templateData = null, + Mock? dispatchData = null) + { + var dd = dispatchData ?? new Mock(); + dd.Setup(d => d.GenerateNextPONumberAsync()).ReturnsAsync("1"); + dd.Setup(d => d.GenerateNextDispatchNumberAsync()).ReturnsAsync("DSP-1"); + dd.Setup(d => d.AddAsync(It.IsAny())) + .ReturnsAsync((Dispatch d) => d); + + var emailPort = new Mock(); + emailPort.Setup(e => e.SendDispatchEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var tokenPort = new Mock(); + tokenPort.Setup(t => t.GetOrCreateActiveTokenAsync(It.IsAny())).ReturnsAsync("token"); + + return new WorkOrderDispatchService( + (woData ?? new Mock()).Object, + dd.Object, + (vendorData ?? new Mock()).Object, + Mock.Of(), + (templateData ?? new Mock()).Object, + Mock.Of(), + emailPort.Object, + tokenPort.Object, + Microsoft.Extensions.Options.Options.Create(new FrontendOptions())); + } + + private static Mock WorkOrderDataReturning(params WorkOrder[] workOrders) => + WorkOrderDataReturning(new Mock(), workOrders); + + private static Mock WorkOrderDataReturning(Mock mock, params WorkOrder[] workOrders) + { + mock.Setup(d => d.GetByRequestedIdsWithDetailsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(workOrders.ToList()); + return mock; + } + + private static Mock VendorDataReturning(params Vendor[] vendors) + { + var mock = new Mock(); + mock.Setup(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(vendors.ToList()); + return mock; + } + + [Fact] + public async Task Dispatch_PrimaryWorkOrderFollowsReturnedOrder() + { + var woData = WorkOrderDataReturning( + new WorkOrder { Id = 30, InternalWONumber = "WO-30" }, + new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); + var dispatchData = new Mock(); + var service = BuildService(woData, VendorDataReturning(new Vendor { Id = 1, CompanyName = "V", Email = "v@x.io" }), dispatchData: dispatchData); + + Dispatch? saved = null; + dispatchData.Setup(d => d.AddAsync(It.IsAny())) + .ReturnsAsync((Dispatch d) => { saved = d; return d; }); + + await service.DispatchToVendorAsync(new DispatchToVendorInput + { + WorkOrderIds = new() { 30, 10 }, + VendorIds = new() { 1 } + }, CancellationToken.None); + + saved.Should().NotBeNull(); + saved!.WorkOrderId.Should().Be(30); + } + + [Fact] + public async Task Dispatch_DeduplicatesVendorsAndSkipsMissing() + { + var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); + var vendorData = new Mock(); + vendorData.Setup(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = 2, CompanyName = "Only Co", Email = "v@x.io" } }); + + var service = BuildService(woData, vendorData); + + var result = await service.DispatchToVendorAsync(new DispatchToVendorInput + { + WorkOrderIds = new() { 10 }, + VendorIds = new() { 2, 2, 999 } + }, CancellationToken.None); + + result.Dispatches.Should().HaveCount(1); + result.Dispatches.Single().VendorName.Should().Be("Only Co"); + vendorData.Verify(d => d.GetByIdsOrderedAsync( + It.Is>(ids => ids.SequenceEqual(new[] { 2, 999 })), + It.IsAny()), Times.Once); + vendorData.Verify(d => d.GetByIdAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dispatch_FetchesTemplateOnceRegardlessOfVendorCount() + { + var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); + var templateData = new Mock(); + templateData.Setup(d => d.GetByIdWithItemsAsync(7)) + .ReturnsAsync(new TaskListTemplate + { + Items = new List + { + new() { ItemText = "A", SortOrder = 0 }, + new() { ItemText = "B", SortOrder = 1 } + } + }); + var vendorData = VendorDataReturning( + new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" }, + new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" }); + + var service = BuildService(woData, vendorData, templateData); + + await service.DispatchToVendorAsync(new DispatchToVendorInput + { + WorkOrderIds = new() { 10 }, + VendorIds = new() { 1, 2 }, + TaskListTemplateId = 7 + }, CancellationToken.None); + + templateData.Verify(d => d.GetByIdWithItemsAsync(7), Times.Once); + } + + [Fact] + public async Task Dispatch_PerformsSingleBatchedVendorLookup() + { + var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); + var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" }); + + var service = BuildService(woData, vendorData); + + await service.DispatchToVendorAsync(new DispatchToVendorInput + { + WorkOrderIds = new() { 10 }, + VendorIds = new() { 1, 2, 3 } + }, CancellationToken.None); + + vendorData.Verify(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny()), Times.Once); + vendorData.Verify(d => d.GetByIdAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dispatch_BatchesAuditLogsOncePerVendorAndNeverSingles() + { + var woData = WorkOrderDataReturning( + new WorkOrder { Id = 10, InternalWONumber = "WO-10" }, + new WorkOrder { Id = 11, InternalWONumber = "WO-11" }); + var vendorData = VendorDataReturning( + new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" }, + new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" }); + + var service = BuildService(woData, vendorData); + + await service.DispatchToVendorAsync(new DispatchToVendorInput + { + WorkOrderIds = new() { 10, 11 }, + VendorIds = new() { 1, 2 } + }, CancellationToken.None); + + woData.Verify(d => d.AddAuditLogsAsync(It.Is>(logs => logs.Count == 2)), Times.Exactly(2)); + woData.Verify(d => d.AddAuditLogAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dispatch_NoWorkOrders_Throws() + { + var woData = WorkOrderDataReturning(); + var service = BuildService(woData, VendorDataReturning()); + + var act = () => service.DispatchToVendorAsync(new DispatchToVendorInput + { + WorkOrderIds = new() { 404 }, + VendorIds = new() { 1 } + }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Dispatch_ForwardsSameCancellationTokenToBothBatchDataServices() + { + var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); + var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" }); + var service = BuildService(woData, vendorData); + + using var cts = new CancellationTokenSource(); + var token = cts.Token; + + await service.DispatchToVendorAsync(new DispatchToVendorInput + { + WorkOrderIds = new() { 10 }, + VendorIds = new() { 1 } + }, token); + + woData.Verify(d => d.GetByRequestedIdsWithDetailsAsync( + It.IsAny>(), + token), Times.Once); + vendorData.Verify(d => d.GetByIdsOrderedAsync( + It.IsAny>(), + token), Times.Once); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs b/Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs new file mode 100644 index 0000000..90b93ee --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs @@ -0,0 +1,77 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DispatchControllerTests +{ + private static DispatchController NewController(Mock service) => + new(service.Object); + + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + [Fact] + public async Task List_ReturnsDataResponseWithPaginationShape() + { + var service = new Mock(); + service.Setup(s => s.GetDispatchListAsync("a", null, null, null, null, "dispatchedAt", "desc", 1, 25, It.IsAny())) + .ReturnsAsync(new DispatchListResultDTO + { + Total = 2, + Page = 1, + PageSize = 25, + Items = new List + { + new() { Id = 1, DispatchNumber = "D1" }, + new() { Id = 2, DispatchNumber = "D2" } + } + }); + + var result = await NewController(service).List("a", null, null, null, null, "dispatchedAt", "desc", 1, 25, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success"); + object data = response.Data!; + Prop(data, "total").Should().Be(2); + Prop(data, "page").Should().Be(1); + Prop(data, "pageSize").Should().Be(25); + ((System.Collections.IEnumerable)Prop(data, "items")).Cast().Count().Should().Be(2); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.GetDispatchDetailAsync(99, It.IsAny())) + .ReturnsAsync((DispatchDetailResultDTO?)null); + + var result = await NewController(service).GetById(99, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Dispatch not found"); + } + + [Fact] + public async Task GetById_WhenFound_ReturnsDetailAsData() + { + var service = new Mock(); + var detail = new DispatchDetailResultDTO { Id = 5, DispatchNumber = "D5", Status = "Pending" }; + service.Setup(s => s.GetDispatchDetailAsync(5, It.IsAny())) + .ReturnsAsync(detail); + + var result = await NewController(service).GetById(5, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success"); + ((object?)response.Data).Should().BeSameAs(detail); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs b/Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs new file mode 100644 index 0000000..b4f9654 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs @@ -0,0 +1,117 @@ +using FluentAssertions; +using Moq; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DispatchServiceTests +{ + private static DispatchService NewService(Mock data) => + new(data.Object, new CreateDispatchValidation(), new UpdateDispatchValidation()); + + [Fact] + public async Task GetDispatchListAsync_MapsDataReadModelToServiceDtoPreservingFields() + { + var data = new Mock(); + var source = new DispatchListResult + { + Total = 1, + Page = 2, + PageSize = 25, + Items = new List + { + new() + { + Id = 7, + DispatchNumber = "DISP-7", + PONumber = "PO-1", + NTEAmount = 200m, + Status = "Pending", + VendorId = 9, + VendorName = "Acme", + WorkOrderId = 3, + PrimaryWOInternalNumber = "WO-3", + PrimaryWOTitle = "Fix AC", + LocationName = "Site A", + WorkOrderCount = 2, + PendingUpliftCount = 1 + } + } + }; + data.Setup(d => d.GetListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(source); + + var result = await NewService(data).GetDispatchListAsync("a", "Pending", 9, null, null, "ponumber", "asc", 2, 25, CancellationToken.None); + + result.Should().BeOfType(); + result.Total.Should().Be(1); + result.Page.Should().Be(2); + result.PageSize.Should().Be(25); + var row = result.Items.Single(); + row.Id.Should().Be(7); + row.DispatchNumber.Should().Be("DISP-7"); + row.PONumber.Should().Be("PO-1"); + row.NTEAmount.Should().Be(200m); + row.Status.Should().Be("Pending"); + row.VendorId.Should().Be(9); + row.VendorName.Should().Be("Acme"); + row.WorkOrderId.Should().Be(3); + row.PrimaryWOInternalNumber.Should().Be("WO-3"); + row.PrimaryWOTitle.Should().Be("Fix AC"); + row.LocationName.Should().Be("Site A"); + row.WorkOrderCount.Should().Be(2); + row.PendingUpliftCount.Should().Be(1); + } + + [Fact] + public async Task GetDispatchDetailAsync_MapsNestedCollectionsPreservingShape() + { + var data = new Mock(); + var detail = new DispatchDetailDTO + { + Id = 42, + DispatchNumber = "DISP-42", + PONumber = "PO-9", + NTEAmount = 500m, + Status = "Dispatched", + Description = "desc", + WorkOrderId = 11, + EmailSent = true, + Vendor = new DispatchDetailVendorDTO { Id = 1, CompanyName = "VendorCo", Email = "v@x.com" }, + PrimaryLocation = new DispatchPrimaryLocationDTO { Id = 5, Name = "Loc", City = "Austin" }, + WorkOrders = new List { new() { WorkOrderId = 11, Title = "T", Priority = "High" } }, + Checklist = new List { new() { Id = 1, ItemText = "Check", IsCompleted = true, SortOrder = 1 } }, + Signoffs = new List { new() { Id = 1, SignoffType = "Vendor", Name = "J" } }, + Comments = new List { new() { Id = 1, Commenttext = "Hi", UserName = "Sara" } }, + UpliftRequests = new List { new() { Id = 1, RequestedNTE = 600m, CurrentNTE = 500m, Delta = 100m, Status = "Pending" } }, + AuditLog = new List { new() { Id = 1, Action = "Updated", FieldName = "DISP-42:Status" } } + }; + data.Setup(d => d.GetDetailAsync(42, It.IsAny())).ReturnsAsync(detail); + data.Setup(d => d.GetDetailAsync(999, It.IsAny())).ReturnsAsync((DispatchDetailDTO?)null); + + var service = NewService(data); + + var found = await service.GetDispatchDetailAsync(42, CancellationToken.None); + var missing = await service.GetDispatchDetailAsync(999, CancellationToken.None); + + missing.Should().BeNull(); + found.Should().BeOfType(); + found!.Id.Should().Be(42); + found.DispatchNumber.Should().Be("DISP-42"); + found.EmailSent.Should().BeTrue(); + found.Vendor!.CompanyName.Should().Be("VendorCo"); + found.PrimaryLocation!.Name.Should().Be("Loc"); + found.WorkOrders.Single().Title.Should().Be("T"); + found.Checklist.Single().IsCompleted.Should().BeTrue(); + found.Signoffs.Single().Name.Should().Be("J"); + found.Comments.Single().UserName.Should().Be("Sara"); + found.UpliftRequests.Single().Delta.Should().Be(100m); + found.AuditLog.Single().Action.Should().Be("Updated"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs b/Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs new file mode 100644 index 0000000..ce6a3f7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs @@ -0,0 +1,146 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DropdownOptionsServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static DropdownOptionsService NewService(ApplicationDbContext ctx) => + new(new DropdownOptionsDataService(ctx)); + + private static DropdownOption Option(string category, string value, string? parent = null, int sort = 0, bool active = true) => + new() { Category = category, Value = value, ParentValue = parent, SortOrder = sort, IsActive = active }; + + [Fact] + public async Task GetAllAsync_FiltersActiveAndOrdersBySortThenValue() + { + using var ctx = NewContext(); + ctx.DropdownOptions.AddRange( + Option("Color", "Red", sort: 2), + Option("Color", "Blue", sort: 1), + Option("Color", "Green", sort: 1, active: false), + Option("Shape", "Circle") + ); + ctx.SaveChanges(); + + var result = (await NewService(ctx).GetAllAsync(null, CancellationToken.None)).ToList(); + + result.Should().HaveCount(3); + result.Select(d => d.Value).Should().ContainInOrder("Blue", "Red", "Circle"); + } + + [Fact] + public async Task GetByCategoryAsync_FiltersByCategoryAndParent() + { + using var ctx = NewContext(); + ctx.DropdownOptions.AddRange( + Option("State", "TX", parent: "US"), + Option("State", "CA", parent: "US"), + Option("State", "ON", parent: "CA") + ); + ctx.SaveChanges(); + + var result = (await NewService(ctx).GetByCategoryAsync("State", "US", CancellationToken.None)).ToList(); + + result.Select(d => d.Value).Should().BeEquivalentTo(new[] { "TX", "CA" }); + } + + [Fact] + public async Task CreateAsync_WhenCategoryOrValueMissing_Throws() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).CreateAsync(new DropdownOptionDTO { Category = "", Value = "X" }, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateAsync_WhenDuplicate_Throws() + { + using var ctx = NewContext(); + ctx.DropdownOptions.Add(Option("Color", "Red", parent: "Primary")); + ctx.SaveChanges(); + + var act = () => NewService(ctx).CreateAsync(new DropdownOptionDTO { Category = "Color", Value = "Red", ParentValue = "Primary" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateAsync_ForcesActiveTrueAndReturnsDtoWithId() + { + using var ctx = NewContext(); + var dto = await NewService(ctx).CreateAsync( + new DropdownOptionDTO { Category = "Color", Value = "Red", IsActive = false }, CancellationToken.None); + + dto.Id.Should().BeGreaterThan(0); + dto.IsActive.Should().BeTrue(); + ctx.DropdownOptions.Single().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task UpdateAsync_WhenNotFound_ThrowsKeyNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).UpdateAsync(404, new DropdownOptionDTO { Category = "Color", Value = "Red" }, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpdateAsync_UpdatesValueParentSortActiveButNotCategory() + { + using var ctx = NewContext(); + var existing = Option("Color", "Red"); + ctx.DropdownOptions.Add(existing); + ctx.SaveChanges(); + + await NewService(ctx).UpdateAsync(existing.Id, new DropdownOptionDTO + { + Category = "Ignored", + Value = "Crimson", + ParentValue = "Warm", + SortOrder = 5, + IsActive = false + }, CancellationToken.None); + + var row = ctx.DropdownOptions.Single(); + row.Category.Should().Be("Color"); + row.Value.Should().Be("Crimson"); + row.ParentValue.Should().Be("Warm"); + row.SortOrder.Should().Be(5); + row.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_WhenNotFound_ThrowsKeyNotFound() + { + using var ctx = NewContext(); + var act = () => NewService(ctx).DeleteAsync(404, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteAsync_RemovesOption() + { + using var ctx = NewContext(); + var existing = Option("Color", "Red"); + ctx.DropdownOptions.Add(existing); + ctx.SaveChanges(); + + await NewService(ctx).DeleteAsync(existing.Id, CancellationToken.None); + + ctx.DropdownOptions.Should().BeEmpty(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs b/Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs new file mode 100644 index 0000000..c91d2e4 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs @@ -0,0 +1,135 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class FollowUpControllerTests +{ + private static FollowUpController NewController(Mock service) + { + var controller = new FollowUpController(service.Object, Mock.Of>()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "user-1") + }, "Test")) + } + } + }; + return controller; + } + + private static FollowUpController NewControllerNoUser(Mock service) => + new(service.Object, Mock.Of>()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity()) + } + } + }; + + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + [Fact] + public async Task GetFollowUps_ReturnsPaginationEnvelopeWithMappedRows() + { + var service = new Mock(); + service.Setup(s => s.GetFollowUpListAsync(1, 12, "", "Open", It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List { new() { Id = 1, Reason = "R", CreatedByName = "Me" } }, + TotalCount = 1, + Page = 1, + PageSize = 12 + }); + + var result = await NewController(service).GetFollowUps(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.PageSize.Should().Be(12); + envelope.TotalCount.Should().Be(1); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.GetFollowUpDetailAsync(9, It.IsAny())) + .ReturnsAsync((FollowUpDetailDTO?)null); + + var result = await NewController(service).GetById(9, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Create_WhenNoUser_ReturnsUnauthorized() + { + var service = new Mock(); + var result = await NewControllerNoUser(service).Create(new FollowUp_DTO { Reason = "x" }, CancellationToken.None); + + var unauthorized = result.Should().BeOfType().Subject; + unauthorized.Value.Should().BeOfType().Subject.Message.Should().Be("User not authenticated"); + service.Verify(s => s.CreateOpenFollowUpAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_WhenAuthenticated_ReturnsCreatedWithId() + { + var service = new Mock(); + service.Setup(s => s.CreateOpenFollowUpAsync(It.IsAny(), "user-1", It.IsAny())) + .ReturnsAsync(42); + + var result = await NewController(service).Create(new FollowUp_DTO { Reason = "x" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Follow Up created"); + ((object?)response.Data).Should().Be(42); + } + + [Fact] + public async Task Complete_WhenMissing_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.SetFollowUpStatusAsync(3, "Completed", It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).Complete(3, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_WhenMissing_ReturnsNotFoundEnvelope() + { + var service = new Mock(); + service.Setup(s => s.RemoveFollowUpAsync(8, It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).Delete(8, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Follow Up not found"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs b/Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs new file mode 100644 index 0000000..d5d7790 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs @@ -0,0 +1,162 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class FollowUpServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static FollowUpService NewService(ApplicationDbContext ctx) => + new(new FollowUpDataService(ctx), new CreateFollowUpValidation(), new UpdateFollowUpValidation()); + + private static FollowUps SeedFollowUp(ApplicationDbContext ctx, string reason, string status = "Open") + { + var f = new FollowUps + { + Reason = reason, + ScheduleDate = new DateTime(2026, 8, 1), + ScheduleStartTime = new TimeSpan(9, 0, 0), + Status = status, + createdby = "user-1", + CreatedDate = DateTime.Now + }; + ctx.FollowUps.Add(f); + ctx.SaveChanges(); + return f; + } + + [Fact] + public async Task CreateOpenFollowUpAsync_ForcesOpenStatusAndPersistsFields() + { + using var ctx = NewContext(); + var service = NewService(ctx); + + var id = await service.CreateOpenFollowUpAsync(new FollowUpCreateRequestDTO + { + Reason = "Call back", + ScheduleDate = new DateTime(2026, 9, 1), + ScheduleStartTime = new TimeSpan(10, 30, 0), + EmployeeId = 5, + WorkOrderId = 7, + AccountId = 9, + LocationId = 11 + }, "user-9", CancellationToken.None); + + var entity = ctx.FollowUps.Single(); + entity.Id.Should().Be(id); + entity.Status.Should().Be("Open"); + entity.Reason.Should().Be("Call back"); + entity.createdby.Should().Be("user-9"); + entity.EmployeeId.Should().Be(5); + entity.WorkOrderId.Should().Be(7); + entity.CreatedDate.Should().NotBeNull(); + } + + [Fact] + public async Task GetFollowUpListAsync_DefaultsToOpenAndMapsShape() + { + using var ctx = NewContext(); + SeedFollowUp(ctx, "Open one", "Open"); + SeedFollowUp(ctx, "Done one", "Completed"); + + var page = await NewService(ctx).GetFollowUpListAsync(1, 12, "", null, CancellationToken.None); + + page.Items.Should().HaveCount(1); + page.Items.Single().Reason.Should().Be("Open one"); + page.Items.Single().Status.Should().Be("Open"); + page.PageSize.Should().Be(12); + } + + [Fact] + public async Task GetFollowUpDetailAsync_MapsRelatedNamesAndResolvesCreator() + { + using var ctx = NewContext(); + var employee = new Employee { FirstName = "Sara", LastName = "Lee" }; + ctx.Employees.Add(employee); + ctx.SaveChanges(); + + var account = new Accounts { Name = "Acme" }; + ctx.Accounts.Add(account); + ctx.SaveChanges(); + + var user = new ApplicationUser { Id = "user-1", FirstName = "Creator", LastName = "One", UserName = "cone" }; + var fu = new FollowUps + { + Reason = "Detail", + ScheduleDate = new DateTime(2026, 8, 2), + ScheduleStartTime = new TimeSpan(8, 0, 0), + Status = "Open", + EmployeeId = employee.Id, + AccountId = account.Id, + createdby = "user-1", + CreatedDate = DateTime.Now + }; + ctx.Users.Add(user); + ctx.FollowUps.Add(fu); + ctx.SaveChanges(); + + var detail = await NewService(ctx).GetFollowUpDetailAsync(fu.Id, CancellationToken.None); + + detail.Should().NotBeNull(); + detail!.AssignedTo.Should().Be("Sara Lee"); + detail.AccountName.Should().Be("Acme"); + detail.CreatedById.Should().Be("user-1"); + detail.CreatedByName.Should().Be("Creator One"); + } + + [Fact] + public async Task UpdateFollowUpFieldsAsync_UpdatesAndThrowsWhenMissing() + { + using var ctx = NewContext(); + var existing = SeedFollowUp(ctx, "Old reason"); + + await NewService(ctx).UpdateFollowUpFieldsAsync(existing.Id, new FollowUpUpdateRequestDTO + { + Id = existing.Id, + Reason = "New reason", + ScheduleDate = new DateTime(2026, 10, 1), + ScheduleStartTime = new TimeSpan(11, 0, 0) + }, CancellationToken.None); + + var row = ctx.FollowUps.Single(); + row.Reason.Should().Be("New reason"); + + var act = () => NewService(ctx).UpdateFollowUpFieldsAsync(existing.Id + 999, new FollowUpUpdateRequestDTO { Id = existing.Id + 999 }, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SetFollowUpStatusAsync_TogglesStatusAndReturnsFalseWhenMissing() + { + using var ctx = NewContext(); + var existing = SeedFollowUp(ctx, "To complete"); + + (await NewService(ctx).SetFollowUpStatusAsync(existing.Id, "Completed", CancellationToken.None)).Should().BeTrue(); + ctx.FollowUps.Single().Status.Should().Be("Completed"); + (await NewService(ctx).SetFollowUpStatusAsync(existing.Id + 999, "Completed", CancellationToken.None)).Should().BeFalse(); + } + + [Fact] + public async Task RemoveFollowUpAsync_RemovesAndReturnsFalseWhenMissing() + { + using var ctx = NewContext(); + var existing = SeedFollowUp(ctx, "Gone"); + + (await NewService(ctx).RemoveFollowUpAsync(existing.Id, CancellationToken.None)).Should().BeTrue(); + (await NewService(ctx).RemoveFollowUpAsync(existing.Id, CancellationToken.None)).Should().BeFalse(); + ctx.FollowUps.Should().BeEmpty(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs b/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs new file mode 100644 index 0000000..d1d7906 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs @@ -0,0 +1,42 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Api.SeaHavenIndustries.Tests; + +internal static class IdentityTestHelpers +{ + public static (UserManager manager, Mock> store, Mock> hasher) CreateUserManager() + { + var store = new Mock>(); + var hasher = new Mock>(); + + store.As>(); + + var manager = new UserManager( + store.Object, + Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), + hasher.Object, + new List>(), + new List>(), + new Mock().Object, + new IdentityErrorDescriber(), + new Mock().Object, + new Mock>>().Object); + + return (manager, store, hasher); + } + + public static ApplicationUser User(string id = "u1", string? userName = "alice", bool? isDeleted = false) => new() + { + Id = id, + UserName = userName, + Email = userName + "@example.com", + FirstName = "Alice", + LastName = "Q", + PhoneNumber = "555", + IsDeleted = isDeleted + }; +} diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs index 5074c22..ebbd3d6 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Moq; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.DTOs; @@ -31,7 +32,7 @@ public class LocationControllerSitesTests Mock.Of(), Mock.Of()); - var controller = new LocationController(service, dataService) + var controller = new LocationController(service, Mock.Of>()) { ControllerContext = new ControllerContext { diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs new file mode 100644 index 0000000..d6dc573 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -0,0 +1,105 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationControllerTests +{ + private static LocationController NewController(Mock service) => + new(service.Object, Mock.Of>()); + + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + [Fact] + public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData() + { + var service = new Mock(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List { new() { Id = 1, Name = "Site" } }, + TotalCount = 1, + Page = 1, + PageSize = 10 + }); + + var result = await NewController(service).GetLocationList("a", 1, 10, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.PageNumber.Should().Be(1); + envelope.PageSize.Should().Be(10); + envelope.TotalCount.Should().Be(1); + envelope.TotalPages.Should().Be(1); + } + + [Fact] + public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity() + { + var service = new Mock(); + service.Setup(s => s.GetLocationDetailAsync(5, It.IsAny())) + .ReturnsAsync((LocationDTO?)null); + + var result = await NewController(service).GetLocationById(5, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + var response = notFound.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Location not found"); + } + + [Fact] + public async Task GetLocationById_WhenFound_MapsServiceDtoToAliasedShape() + { + var service = new Mock(); + service.Setup(s => s.GetLocationDetailAsync(7, It.IsAny())) + .ReturnsAsync(new LocationDTO { Id = 7, Name = "N", Address1 = "A", Zip = "Z", PhoneNumber = "P", Email = "E", Status = "Active" }); + + var result = await NewController(service).GetLocationById(7, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var body = ok.Value!; + Prop(body, "Id").Should().Be(7); + Prop(body, "Name").Should().Be("N"); + Prop(body, "Address").Should().Be("A"); + Prop(body, "ZipCode").Should().Be("Z"); + Prop(body, "Phone").Should().Be("P"); + Prop(body, "ContactEmail").Should().Be("E"); + Prop(body, "Status").Should().Be("Active"); + } + + [Fact] + public async Task AddLocation_WhenServiceSucceeds_ReturnsCreatedResponse() + { + var service = new Mock(); + var result = await NewController(service).AddLocation(new Location_DTO { Name = "X" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Location Created Successfully"); + service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditLocation_WhenMissing_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny(), It.IsAny())) + .ThrowsAsync(new KeyNotFoundException()); + + var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Location not found"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs new file mode 100644 index 0000000..5f3abe2 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -0,0 +1,133 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static LocationService NewService(ApplicationDbContext ctx) => + new(new LocationDataService(ctx), new CreateLocationValidation(), new UpdateLocationValidation()); + + private static Locations SeedLocation(ApplicationDbContext ctx, string name, string? city = null, string? status = "Active") + { + var loc = new Locations { Name = name, City = city, Status = status, CreatedDate = DateTime.Now }; + ctx.Locations.Add(loc); + ctx.SaveChanges(); + return loc; + } + + [Fact] + public async Task CreateLocationFromRequestAsync_PersistsMappedFields() + { + using var ctx = NewContext(); + var service = NewService(ctx); + + await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO + { + Name = "Warehouse", + Title = "Main WH", + Address = "1 Depot Rd", + City = "Austin", + State = "TX", + ZipCode = "73301", + Phone = "555-1000", + ContactEmail = "wh@example.com", + Status = "Active" + }, CancellationToken.None); + + var entity = ctx.Locations.Single(); + entity.Name.Should().Be("Warehouse"); + entity.Title.Should().Be("Main WH"); + entity.Address1.Should().Be("1 Depot Rd"); + entity.City.Should().Be("Austin"); + entity.State.Should().Be("TX"); + entity.Zip.Should().Be("73301"); + entity.PhoneNumber.Should().Be("555-1000"); + entity.Email.Should().Be("wh@example.com"); + entity.Status.Should().Be("Active"); + } + + [Fact] + public async Task GetLocationListPagedAsync_FiltersBySearchAndMapsShape() + { + using var ctx = NewContext(); + SeedLocation(ctx, "Alpha Site", "Austin"); + SeedLocation(ctx, "Beta Site", "Dallas"); + SeedLocation(ctx, "Gamma Yard", "Austin"); + + var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None); + + page.Items.Should().HaveCount(2); + page.TotalCount.Should().Be(2); + page.Items.All(l => l.City == "Austin").Should().BeTrue(); + page.Items.Select(l => l.Name).Should().NotBeNull(); + } + + [Fact] + public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull() + { + using var ctx = NewContext(); + var existing = SeedLocation(ctx, "Depot", "Houston"); + + var service = NewService(ctx); + var found = await service.GetLocationDetailAsync(existing.Id, CancellationToken.None); + var missing = await service.GetLocationDetailAsync(existing.Id + 999, CancellationToken.None); + + missing.Should().BeNull(); + found.Should().NotBeNull(); + found!.Name.Should().Be("Depot"); + found.City.Should().Be("Houston"); + } + + [Fact] + public async Task UpdateLocationFromRequestAsync_UpdatesFieldsAndThrowsWhenMissing() + { + using var ctx = NewContext(); + var existing = SeedLocation(ctx, "Old", "Round Rock"); + + await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO + { + Name = "New", + Address = "9 New St", + City = "Plano", + Status = "Inactive" + }, CancellationToken.None); + + var row = ctx.Locations.Single(); + row.Name.Should().Be("New"); + row.Address1.Should().Be("9 New St"); + row.City.Should().Be("Plano"); + row.Status.Should().Be("Inactive"); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing() + { + using var ctx = NewContext(); + var existing = SeedLocation(ctx, "Gone", "Cedar Park"); + + var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None); + var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None); + + removed.Should().BeTrue(); + again.Should().BeFalse(); + ctx.Locations.Should().BeEmpty(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/SanitizedErrorsTests.cs b/Api.SeaHavenIndustries.Tests/SanitizedErrorsTests.cs new file mode 100644 index 0000000..c8a5a81 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SanitizedErrorsTests.cs @@ -0,0 +1,71 @@ +using Api.SeaHavenIndustries.Helper; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class SanitizedErrorsTests +{ + [Fact] + public void Sanitize_ReturnsStableMessageAndNeverExposesExceptionText() + { + var logger = new Mock(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + + var message = logger.Object.Sanitize(new InvalidOperationException("SUPER-SECRET-internal-detail")); + + message.Should().NotContain("SUPER-SECRET"); + message.Should().NotContain("internal-detail"); + message.Should().StartWith("An unexpected error occurred"); + message.Should().Contain("reference"); + } + + [Fact] + public void Sanitize_UsesProvidedPublicMessageInsteadOfExceptionText() + { + var logger = new Mock(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + + var message = logger.Object.Sanitize( + new InvalidOperationException("SUPER-SECRET-internal-detail"), + "Vendor not found"); + + message.Should().Contain("Vendor not found"); + message.Should().NotContain("SUPER-SECRET"); + message.Should().Contain("reference"); + } + + [Fact] + public void Sanitize_LogsOriginalExceptionAtErrorLevel() + { + var logger = new Mock(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var exception = new InvalidOperationException("SUPER-SECRET-internal-detail"); + + logger.Object.Sanitize(exception); + + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.Is(ex => ex == exception), + It.IsAny>()), + Times.Once); + } + + [Fact] + public void Sanitize_ProducesUniqueCorrelationReferencesAcrossCalls() + { + var logger = new Mock(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var exception = new InvalidOperationException("detail"); + + var first = logger.Object.Sanitize(exception); + var second = logger.Object.Sanitize(exception); + + first.Should().NotBe(second); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs new file mode 100644 index 0000000..343d5f7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs @@ -0,0 +1,281 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +internal static class TestAsyncEnumerableHelper +{ + public static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable source) + { + foreach (var item in source) + yield return item; + await Task.CompletedTask; + } +} + +public class SyncServiceBehaviorTests +{ + [Fact] + public async Task SyncWorkOrders_CreatesNewAndUpdatesExisting() + { + var data = new Mock(); + var external = new Mock(); + + var existingWO = new WorkOrder { Id = 5, ExternalWorkOrderId = "EXT-1", WorkerOrderTitle = "Old" }; + + data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny())) + .ReturnsAsync("10000005"); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-1", It.IsAny())) + .ReturnsAsync(existingWO); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-2", It.IsAny())) + .ReturnsAsync((WorkOrder?)null); + data.Setup(d => d.ResolveLocationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int?)null); + data.Setup(d => d.GetLocationCountAsync(It.IsAny())) + .ReturnsAsync(3); + + var items = new List> + { + new() { ["work_order_id"] = "EXT-1", ["description"] = "Updated Title", ["wo_status"] = "in_progress" }, + new() { ["work_order_id"] = "EXT-2", ["description"] = "New WO", ["wo_status"] = "new", ["severity"] = "3" } + }; + + external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())) + .Returns(items.ToAsyncEnumerable()); + + var service = new SyncService(data.Object, external.Object); + var result = await service.SyncWorkOrdersAsync(CancellationToken.None); + + Assert.Equal(2, result.Synced); + Assert.Equal(1, result.Created); + Assert.Equal(1, result.Updated); + Assert.Equal(3, result.LocationsCreated); + Assert.Equal("Updated Title", existingWO.WorkerOrderTitle); + Assert.Equal("In Progress", existingWO.Status); + data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.ExternalWorkOrderId == "EXT-2")), Times.Once); + } + + [Fact] + public async Task SyncWorkOrders_MapsStatusAndSeverity() + { + var data = new Mock(); + var external = new Mock(); + + data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny())).ReturnsAsync((string?)null); + data.Setup(d => d.GetWorkOrderByExternalIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((WorkOrder?)null); + data.Setup(d => d.ResolveLocationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int?)null); + data.Setup(d => d.GetLocationCountAsync(It.IsAny())).ReturnsAsync(0); + + var items = new List> + { + new() { ["work_order_id"] = "W1", ["wo_status"] = "completed", ["severity"] = "2" } + }; + external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); + + await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None); + + data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once); + } + + [Fact] + public async Task SyncComments_SkipsExistingAndOrphans() + { + var data = new Mock(); + var external = new Mock(); + var wo = new WorkOrder { Id = 10, ExternalWorkOrderId = "WO-1" }; + + data.Setup(d => d.CommentExistsByExternalIdAsync("C-EXISTS", It.IsAny())).ReturnsAsync(true); + data.Setup(d => d.CommentExistsByExternalIdAsync("C-NEW", It.IsAny())).ReturnsAsync(false); + data.Setup(d => d.CommentExistsByExternalIdAsync("C-ORPHAN", It.IsAny())).ReturnsAsync(false); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-1", It.IsAny())).ReturnsAsync(wo); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-MISSING", It.IsAny())).ReturnsAsync((WorkOrder?)null); + + var items = new List> + { + new() { ["comment_id"] = "C-EXISTS", ["work_order_id"] = "WO-1" }, + new() { ["comment_id"] = "C-NEW", ["work_order_id"] = "WO-1", ["text"] = "Hello" }, + new() { ["comment_id"] = "C-ORPHAN", ["work_order_id"] = "WO-MISSING" } + }; + external.Setup(e => e.ScanCommentsAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); + + var result = await new SyncService(data.Object, external.Object).SyncCommentsAsync(CancellationToken.None); + + Assert.Equal(2, result.Synced); + Assert.Equal(1, result.Created); + Assert.Equal(2, result.Skipped); + data.Verify(d => d.EnqueueComment(It.Is(c => c.ExternalCommentId == "C-NEW")), Times.Once); + } +} + +public class WorkOrderDispatchServiceBehaviorTests +{ + [Fact] + public async Task VerifyDispatch_ThrowsWhenChecklistIncomplete() + { + var dispatchData = CreateVerifyDispatchMocks( + out var dispatch, + incompleteCount: 1, + checklistComplete: false, + hasCustomerSignoff: true, + hasVendorSignoff: true); + dispatch.Id = 1; + + var service = CreateDispatchService(dispatchData); + var ex = await Assert.ThrowsAsync(() => service.VerifyDispatchAsync(1, "user1")); + Assert.Contains("1 checklist item(s) incomplete", ex.Missing); + } + + [Fact] + public async Task VerifyDispatch_ThrowsWhenVendorSignoffMissing() + { + var dispatchData = CreateVerifyDispatchMocks( + out var dispatch, + incompleteCount: 0, + checklistComplete: true, + hasCustomerSignoff: true, + hasVendorSignoff: false); + dispatch.Id = 1; + + var service = CreateDispatchService(dispatchData); + var ex = await Assert.ThrowsAsync(() => service.VerifyDispatchAsync(1, "user1")); + Assert.Contains("Missing Vendor sign-off", ex.Missing); + } + + [Fact] + public async Task VerifyDispatch_SucceedsWhenAllComplete() + { + var dispatchData = CreateVerifyDispatchMocks( + out var dispatch, + incompleteCount: 0, + checklistComplete: true, + hasCustomerSignoff: true, + hasVendorSignoff: true); + dispatch.Id = 1; + + var userMock = new Mock(); + userMock.Setup(u => u.GetByIdAsync("user1")).ReturnsAsync(new ApplicationUser { FirstName = "Jane", LastName = "Doe" }); + + var service = CreateDispatchService(dispatchData, userMock: userMock); + var result = await service.VerifyDispatchAsync(1, "user1"); + + Assert.NotNull(result); + Assert.Equal("Dispatch verified", result!.Message); + Assert.Equal("Verified", dispatch.Status); + Assert.Equal("Jane Doe", dispatch.VerifiedBy); + } + + private static Mock CreateVerifyDispatchMocks( + out Dispatch dispatch, + int incompleteCount, + bool checklistComplete, + bool hasCustomerSignoff, + bool hasVendorSignoff) + { + dispatch = new Dispatch { Id = 1, DispatchNumber = "DSP-00001", WorkOrderId = 10 }; + var mock = new Mock(); + mock.Setup(d => d.GetByIdAsync(1)).ReturnsAsync(dispatch); + + var checklist = checklistComplete + ? new List { new() { IsCompleted = true } } + : new List { new() { IsCompleted = false } }; + mock.Setup(d => d.GetChecklistItemsAsync(1)).ReturnsAsync(checklist); + + var signoffs = new List(); + if (hasCustomerSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Customer" }); + if (hasVendorSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Vendor" }); + mock.Setup(d => d.GetSignoffsAsync(1)).ReturnsAsync(signoffs); + + return mock; + } + + private static WorkOrderDispatchService CreateDispatchService( + Mock? dispatchDataMock = null, + Mock? userMock = null) + { + return new WorkOrderDispatchService( + Mock.Of(), + (dispatchDataMock ?? new Mock()).Object, + Mock.Of(), + (userMock ?? new Mock()).Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions())); + } +} + +public class WorkOrderServiceBehaviorTests +{ + [Fact] + public async Task ChangeStatus_AddsAuditWhenStatusDiffers() + { + var wo = new WorkOrder { Id = 5, Status = "Open" }; + var woData = new Mock(); + woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo); + + var service = CreateWorkOrderService(woData); + var result = await service.ChangeStatusAsync(5, "In Progress", "user1"); + + Assert.NotNull(result); + Assert.Equal("In Progress", wo.Status); + woData.Verify(d => d.AddAuditLogAsync(It.Is(a => + a.FieldName == "Status" && a.OldValue == "Open" && a.NewValue == "In Progress")), Times.Once); + } + + [Fact] + public async Task ChangeStatus_DoesNotAuditWhenStatusSame() + { + var wo = new WorkOrder { Id = 5, Status = "Open" }; + var woData = new Mock(); + woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo); + + await CreateWorkOrderService(woData).ChangeStatusAsync(5, "Open", "user1"); + + woData.Verify(d => d.AddAuditLogAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteWorkOrderCascade_UnlinksQuotesAndDeletesComments() + { + var woData = new Mock(); + woData.Setup(d => d.ExistsAsync(5)).ReturnsAsync(true); + + var commentData = new Mock(); + commentData.Setup(c => c.GetByWorkOrderIdAsync(5)) + .ReturnsAsync(new List { new() { Id = 1 }, new() { Id = 2 } }); + + var quotesData = new Mock(); + + var result = await CreateWorkOrderService(woData, commentData, quotesData).DeleteWorkOrderCascadeAsync(5, "user1"); + + Assert.True(result); + quotesData.Verify(q => q.UnlinkFromWorkOrderAsync(5), Times.Once); + commentData.Verify(c => c.DeleteAsync(1), Times.Once); + commentData.Verify(c => c.DeleteAsync(2), Times.Once); + } + + private static WorkOrderService CreateWorkOrderService( + Mock? woData = null, + Mock? commentData = null, + Mock? quotesData = null) + { + return new WorkOrderService( + (woData ?? new Mock()).Object, + (commentData ?? new Mock()).Object, + Mock.Of(), + (quotesData ?? new Mock()).Object, + Mock.Of(), + Mock.Of(), + Mock.Of()); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs new file mode 100644 index 0000000..84da086 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs @@ -0,0 +1,75 @@ +using System.Reflection; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SeaHaven.DataServices.DependencyInjection; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DependencyInjection; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class ServiceRegistrationTests +{ + private static IServiceCollection BuildConventionServices() + { + var configuration = new ConfigurationBuilder().Build(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(configuration); + services.AddDataServices(); + services.AddBusinessServices(configuration); + return services; + } + + private static IEnumerable<(Type Implementation, Type Interface)> ConventionCandidates(Assembly assembly) + { + return assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType) + .Select(t => ( + Implementation: t, + Interface: t.GetInterfaces().FirstOrDefault(i => i.Name == $"I{t.Name}"))) + .Where(pair => pair.Interface is not null)!; + } + + private static void AssertScopedConventionRegistrations( + IServiceCollection services, + IEnumerable<(Type Implementation, Type Interface)> candidates, + string assemblyLabel) + { + var candidateList = candidates.ToList(); + candidateList.Should().NotBeEmpty( + "expected at least one convention candidate in {0}", assemblyLabel); + + var missing = candidateList + .Where(pair => !services.Any(d => + d.Lifetime == ServiceLifetime.Scoped && + d.ServiceType == pair.Interface && + d.ImplementationType == pair.Implementation)) + .ToList(); + + missing.Should().BeEmpty( + "expected a scoped descriptor mapping each convention candidate interface to its implementation in {0}, missing: {1}", + assemblyLabel, + string.Join("; ", missing.Select(p => $"{p.Interface!.FullName} -> {p.Implementation.FullName}"))); + } + + [Fact] + public void ConventionDi_RegistersEveryServicesImplementationClassAsScoped() + { + var services = BuildConventionServices(); + var candidates = ConventionCandidates(typeof(WorkOrderDispatchService).Assembly); + + AssertScopedConventionRegistrations(services, candidates, "SeaHaven.Services"); + } + + [Fact] + public void ConventionDi_RegistersEveryDataServicesImplementationClassAsScoped() + { + var services = BuildConventionServices(); + var candidates = ConventionCandidates(typeof(VendorDataService).Assembly); + + AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs b/Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs new file mode 100644 index 0000000..b9f3502 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs @@ -0,0 +1,120 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper.ViewModel; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class SettingsControllerTests +{ + private static SettingsController NewController(Mock service) => + new(service.Object, Mock.Of>()); + + [Fact] + public async Task GetCategoryList_ReturnsPaginationEnvelopeWithServiceItems() + { + var service = new Mock(); + service.Setup(s => s.GetCategoryListAsync(1, 10, It.IsAny())) + .ReturnsAsync((new List { new() { Id = 1, Name = "A" } }, 1)); + + var result = await NewController(service).GetCategoryList(1, 10, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.TotalCount.Should().Be(1); + envelope.TotalPages.Should().Be(1); + } + + [Fact] + public async Task GetTemplates_ReturnsTypedServiceDtosInEnvelope() + { + var service = new Mock(); + service.Setup(s => s.GetTemplatesAsync(1, 10, It.IsAny())) + .ReturnsAsync((new List { new() { Id = 1, WorkerOrderTitle = "T" } }, 1)); + + var result = await NewController(service).GetTemplates(1, 10, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.TotalCount.Should().Be(1); + } + + [Fact] + public async Task AddCategory_WhenServiceSucceeds_ReturnsSaved() + { + var service = new Mock(); + var result = await NewController(service).AddCategory(new Category_DTO { Name = "X" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Successfully Saved Data"); + service.Verify(s => s.AddCategoryAsync("X", It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditCategory_WhenMissing_ReturnsBadRequest() + { + var service = new Mock(); + service.Setup(s => s.UpdateCategoryAsync(9, "X", It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).EditCategory(new EditCategory_DTO { Id = 9, Name = "X" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Be("Category not found"); + } + + [Fact] + public async Task DeleteCategory_WhenMissing_ReturnsIdNotMatched() + { + var service = new Mock(); + service.Setup(s => s.DeleteCategoryAsync(9, It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).DeleteCategory(9, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Be("ID not matched!"); + } + + [Fact] + public async Task GetUsers_MapsServiceUsersToUserRoleViewModel() + { + var service = new Mock(); + service.Setup(s => s.GetUsersAsync("", 1, 10, It.IsAny())) + .ReturnsAsync((new List + { + new() { Id = "u1", UserName = "Sara", Email = "s@x.com", RoleName = "User", Status = "Active", Date = "2026-01-01" } + }, 1)); + + var result = await NewController(service).GetUsers("", 1, 10, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + object data = envelope.Data!; + var rows = ((System.Collections.IEnumerable)data).Cast().ToList(); + rows.Should().ContainSingle(u => u.UserName == "Sara" && u.RoleName == "User" && u.Email == "s@x.com"); + } + + [Fact] + public async Task GetRoles_MapsServiceRolesToRolesDto() + { + var service = new Mock(); + service.Setup(s => s.GetRolesAsync(It.IsAny())) + .ReturnsAsync(new List { new() { Name = "Admin", Description = "Full Access" } }); + + var result = await NewController(service).GetRoles(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var roles = ((System.Collections.IEnumerable)ok.Value!).Cast().ToList(); + roles.Should().ContainSingle(r => r.Name == "Admin" && r.Description == "Full Access"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs b/Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs new file mode 100644 index 0000000..b7c50a1 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs @@ -0,0 +1,168 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class SettingsServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static SettingsService NewService(ApplicationDbContext ctx) => + new(new SettingsDataService(ctx), null!); + + private static (ApplicationDbContext ctx, UserManager userManager, RoleManager roleManager) NewIdentityContext() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddIdentity().AddEntityFrameworkStores(); + var provider = services.BuildServiceProvider(); + var scope = provider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + return (ctx, userManager, roleManager); + } + + [Fact] + public async Task AddCategoryAsync_PersistsName() + { + using var ctx = NewContext(); + await NewService(ctx).AddCategoryAsync("Plumbing", CancellationToken.None); + ctx.Categories.Single().Name.Should().Be("Plumbing"); + } + + [Fact] + public async Task UpdateCategoryAsync_UpdatesNameAndReturnsFalseWhenMissing() + { + using var ctx = NewContext(); + ctx.Categories.Add(new Category { Name = "Old" }); + ctx.SaveChanges(); + + var service = NewService(ctx); + (await service.UpdateCategoryAsync(1, "New", CancellationToken.None)).Should().BeTrue(); + ctx.Categories.Single().Name.Should().Be("New"); + (await service.UpdateCategoryAsync(999, "X", CancellationToken.None)).Should().BeFalse(); + } + + [Fact] + public async Task DeleteCategoryAsync_CascadesTemplatesAndWorkOrderCategories() + { + using var ctx = NewContext(); + ctx.Categories.Add(new Category { Id = 1, Name = "C1" }); + ctx.Templates.Add(new Template { CategoryId = 1, WorkerOrderTitle = "T" }); + ctx.workOrderCategories.Add(new WorkOrderCategories { CategoryId = 1 }); + ctx.SaveChanges(); + + var service = NewService(ctx); + (await service.DeleteCategoryAsync(1, CancellationToken.None)).Should().BeTrue(); + (await service.DeleteCategoryAsync(1, CancellationToken.None)).Should().BeFalse(); + + ctx.Categories.Should().BeEmpty(); + ctx.Templates.Should().BeEmpty(); + ctx.workOrderCategories.Should().BeEmpty(); + } + + [Fact] + public async Task GetCategoryListAsync_MapsToServiceDtoAndPages() + { + using var ctx = NewContext(); + ctx.Categories.Add(new Category { Name = "A" }); + ctx.Categories.Add(new Category { Name = "B" }); + ctx.SaveChanges(); + + var (items, total) = await NewService(ctx).GetCategoryListAsync(1, 10, CancellationToken.None); + + total.Should().Be(2); + items.Should().OnlyContain(c => c is SettingsCategoryDTO); + items.Select(c => c.Name).Should().Contain(new[] { "A", "B" }); + } + + [Fact] + public async Task GetTemplatesAsync_MapsWorkOrderScalarsToTypedDto() + { + using var ctx = NewContext(); + var wo = new WorkOrder + { + istemplate = true, + InternalWONumber = "WO-100", + WorkerOrderTitle = "Template A", + Status = "Active", + Priority = "High", + Trade = "HVAC", + VendorNTE = 1500m, + Customer = "Cust" + }; + ctx.workOrders.Add(wo); + ctx.SaveChanges(); + + var (items, total) = await NewService(ctx).GetTemplatesAsync(1, 10, CancellationToken.None); + + total.Should().Be(1); + var dto = items.Single(); + dto.Should().BeOfType(); + dto.InternalWONumber.Should().Be("WO-100"); + dto.WorkerOrderTitle.Should().Be("Template A"); + dto.Status.Should().Be("Active"); + dto.Priority.Should().Be("High"); + dto.Trade.Should().Be("HVAC"); + dto.VendorNTE.Should().Be(1500m); + dto.Customer.Should().Be("Cust"); + dto.istemplate.Should().BeTrue(); + } + + [Fact] + public async Task GetUsersAsync_ExcludesAdminsAndResolvesRole() + { + var (ctx, userManager, roleManager) = NewIdentityContext(); + var t1 = roleManager.CreateAsync(new IdentityRole("Admin")); + var t2 = roleManager.CreateAsync(new IdentityRole("User")); + await Task.WhenAll(t1, t2); + + var admin = new ApplicationUser { UserName = "admin1", Email = "a@x.com", FirstName = "Boss" }; + var worker = new ApplicationUser { UserName = "worker1", Email = "w@x.com", FirstName = "Hand" }; + await userManager.CreateAsync(admin); + await userManager.CreateAsync(worker); + await userManager.AddToRoleAsync(admin, "Admin"); + await userManager.AddToRoleAsync(worker, "User"); + + ctx.SaveChanges(); + + var service = new SettingsService(new SettingsDataService(ctx), userManager); + var (users, total) = await service.GetUsersAsync("", 1, 10, CancellationToken.None); + + total.Should().BeGreaterThanOrEqualTo(2); + users.Should().NotContain(u => u.RoleName == "Admin"); + users.Should().Contain(u => u.UserName == "Hand" && u.RoleName == "User"); + } + + [Fact] + public async Task GetRolesAsync_MapsKnownRoleNamesToDescriptions() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole("Admin")); + ctx.Roles.Add(new IdentityRole("Manager")); + ctx.Roles.Add(new IdentityRole("Unknown")); + ctx.SaveChanges(); + + var roles = await NewService(ctx).GetRolesAsync(CancellationToken.None); + + var byName = roles.ToDictionary(r => r.Name!, r => r.Description); + byName["Admin"].Should().Be("Full Access"); + byName["Manager"].Should().Be("Full access accept quotes"); + roles.Should().NotContain(r => r.Name == "Unknown"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs b/Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs new file mode 100644 index 0000000..7f96501 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs @@ -0,0 +1,146 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class TaskListTemplateServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static TaskListTemplateService NewService(ApplicationDbContext ctx) => + new(new TaskListTemplateDataService(ctx)); + + private static TaskListTemplate Template(string name, bool active, params (string text, int sort)[] items) + { + var t = new TaskListTemplate { Name = name, IsActive = active }; + t.Items = items.Select(i => new TaskListTemplateItem { ItemText = i.text, SortOrder = i.sort }).ToList(); + return t; + } + + [Fact] + public async Task GetAllAsync_ReturnsOnlyActiveOrderedByNameWithOrderedItems() + { + using var ctx = NewContext(); + ctx.TaskListTemplates.AddRange( + Template("Zeta", true, ("b", 2), ("a", 1)), + Template("Alpha", true, ("first", 0)), + Template("Hidden", false) + ); + ctx.SaveChanges(); + + var result = (await NewService(ctx).GetAllAsync(CancellationToken.None)).ToList(); + + result.Select(t => t.Name).Should().ContainInOrder("Alpha", "Zeta"); + result.Single(t => t.Name == "Zeta").Items.Select(i => i.ItemText).Should().ContainInOrder("a", "b"); + } + + [Fact] + public async Task GetByIdAsync_WhenMissing_ReturnsNull() + { + using var ctx = NewContext(); + var result = await NewService(ctx).GetByIdAsync(99, CancellationToken.None); + result.Should().BeNull(); + } + + [Fact] + public async Task GetByIdAsync_ReturnsTemplateWithOrderedItems() + { + using var ctx = NewContext(); + var t = Template("Alpha", true, ("b", 2), ("a", 1)); + ctx.TaskListTemplates.Add(t); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetByIdAsync(t.Id, CancellationToken.None); + + result.Should().NotBeNull(); + result!.Items.Select(i => i.ItemText).Should().ContainInOrder("a", "b"); + } + + [Fact] + public async Task CreateAsync_PersistsTemplateAndItemsAndReturnsId() + { + using var ctx = NewContext(); + var input = new TaskListTemplateInput + { + Name = "New", + Description = "desc", + IsActive = true, + Items = new() + { + new() { ItemText = "two", SortOrder = 0 }, + new() { ItemText = "one", SortOrder = 0 } + } + }; + + var id = await NewService(ctx).CreateAsync(input, CancellationToken.None); + + id.Should().BeGreaterThan(0); + var row = ctx.TaskListTemplates.Include(t => t.Items).Single(); + row.Name.Should().Be("New"); + row.Items.Should().HaveCount(2); + row.Items!.Select(i => i.SortOrder).Should().ContainInOrder(0, 1); + } + + [Fact] + public async Task UpdateAsync_WhenNotFound_ReturnsFalse() + { + using var ctx = NewContext(); + var result = await NewService(ctx).UpdateAsync(404, new TaskListTemplateInput { Name = "x" }, CancellationToken.None); + result.Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_ReplacesItems() + { + using var ctx = NewContext(); + var t = Template("Alpha", true, ("old", 0)); + ctx.TaskListTemplates.Add(t); + ctx.SaveChanges(); + + var updated = await NewService(ctx).UpdateAsync(t.Id, new TaskListTemplateInput + { + Name = "Renamed", + IsActive = false, + Items = new() { new() { ItemText = "new1", SortOrder = 1 }, new() { ItemText = "new2", SortOrder = 2 } } + }, CancellationToken.None); + + updated.Should().BeTrue(); + var row = ctx.TaskListTemplates.Include(x => x.Items).Single(); + row.Name.Should().Be("Renamed"); + row.IsActive.Should().BeFalse(); + row.Items!.Select(i => i.ItemText).Should().BeEquivalentTo(new[] { "new1", "new2" }); + } + + [Fact] + public async Task DeleteAsync_SoftDeletesByDeactivating() + { + using var ctx = NewContext(); + var t = Template("Alpha", true); + ctx.TaskListTemplates.Add(t); + ctx.SaveChanges(); + + var result = await NewService(ctx).DeleteAsync(t.Id, CancellationToken.None); + + result.Should().BeTrue(); + ctx.TaskListTemplates.Single().IsActive.Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_WhenNotFound_ReturnsFalse() + { + using var ctx = NewContext(); + var result = await NewService(ctx).DeleteAsync(404, CancellationToken.None); + result.Should().BeFalse(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs new file mode 100644 index 0000000..d5a031e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -0,0 +1,213 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class UpliftControllerTests +{ + private static UpliftController NewController(Mock service, params string[] roles) + { + var controller = new UpliftController(service.Object, Mock.Of>()); + var claims = new List { new(ClaimTypes.NameIdentifier, "42") }; + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")) + } + }; + return controller; + } + + [Fact] + public async Task List_ReturnsPagedResult() + { + var service = new Mock(); + var dto = new UpliftListResultDTO + { + Total = 1, + Page = 1, + PageSize = 25, + Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } } + }; + service.Setup(x => x.ListAsync(It.IsAny(), "Pending", null, 1, 25, It.IsAny())) + .ReturnsAsync(dto); + + var controller = NewController(service); + + var result = await controller.List(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + } + + [Fact] + public async Task Approve_NotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 99, null, It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("not found")); + + var controller = NewController(service); + + var result = await controller.Approve(99, null); + + var nf = result.Should().BeOfType().Subject; + nf.Value.Should().BeOfType(); + } + + [Fact] + public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException("SECRET-tier-restriction-detail")); + + var logger = new Mock>(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var controller = new UpliftController(service.Object, logger.Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "Test")) + } + }; + + var result = await controller.Approve(5, null); + + var status = result.Should().BeOfType().Subject; + status.StatusCode.Should().Be(403); + var response = status.Value.Should().BeOfType().Subject; + response.Message.Should().NotContain("SECRET"); + response.Message.Should().Contain("reference"); + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.Is(ex => ex.Message.Contains("SECRET")), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Approve_ForbiddenTier_Returns403() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException("requires Tier 2")); + + var controller = NewController(service); + + var result = await controller.Approve(5, null); + + var status = result.Should().BeOfType().Subject; + status.StatusCode.Should().Be(403); + } + + [Fact] + public async Task Approve_AlreadyDecided_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("already decided")); + + var controller = NewController(service); + + var result = await controller.Approve(5, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Approve_Success_ReturnsApprovedResult() + { + var service = new Mock(); + var approved = new UpliftApproveResultDTO { Id = 5, Status = "Approved", NTEAmount = 750m }; + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, "ok", It.IsAny())) + .ReturnsAsync(approved); + + var controller = NewController(service); + + var result = await controller.Approve(5, new UpliftController.DecisionRequest { Note = "ok" }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var approveData = envelope.Data as UpliftApproveResultDTO; + approveData.Should().Be(approved); + } + + [Fact] + public async Task Deny_WithoutNote_Returns400() + { + var service = new Mock(); + service.Setup(x => x.DenyAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("A note is required when denying")); + + var controller = NewController(service); + + var result = await controller.Deny(5, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Deny_Success_ReturnsDeniedResult() + { + var service = new Mock(); + var denied = new UpliftDenyResultDTO { Id = 5, Status = "Denied" }; + service.Setup(x => x.DenyAsync(It.IsAny(), 5, "too high", It.IsAny())) + .ReturnsAsync(denied); + + var controller = NewController(service); + + var result = await controller.Deny(5, new UpliftController.DecisionRequest { Note = "too high" }); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.DenyAsync(It.IsAny(), 5, "too high", It.IsAny()), Times.Once); + } + + [Fact] + public void CanApprove_DelegatesToService() + { + var service = new Mock(); + service.Setup(x => x.CanApprove(It.IsAny(), 2)) + .Returns(true); + + var controller = NewController(service, "Manager"); + + var result = controller.CanApprove(2); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.CanApprove(It.Is(p => p.IsInRole("Manager")), 2), Times.Once); + } + + [Fact] + public async Task ListForDispatch_ReturnsItems() + { + var service = new Mock(); + var items = new[] { new UpliftForDispatchDTO { Id = 1, DispatchId = 10 } }; + service.Setup(x => x.ListForDispatchAsync(It.IsAny(), 10, It.IsAny())) + .ReturnsAsync(items); + + var controller = NewController(service); + + var result = await controller.ListForDispatch(10); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UserControllerTests.cs b/Api.SeaHavenIndustries.Tests/UserControllerTests.cs new file mode 100644 index 0000000..620e461 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UserControllerTests.cs @@ -0,0 +1,191 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using System.Text.Json; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class UserControllerTests +{ + private static string Json(object? value) => + JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object)); + + private static UserController NewController(Mock service, string? userId = null) + { + var controller = new UserController(service.Object, Mock.Of>()); + if (userId != null) + { + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "Test")) + } + }; + } + return controller; + } + + [Fact] + public async Task GetUsers_ReturnsServiceList() + { + var service = new Mock(); + service.Setup(s => s.GetUsersAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", Date = "Jan 01 2026" } + }); + + var controller = NewController(service); + + var result = await controller.GetUsers(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeAssignableTo>(); + } + + [Fact] + public async Task AddUser_Success_ReturnsUpdatedSuccessfully() + { + var service = new Mock(); + service.Setup(s => s.AddUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = true }); + + var controller = NewController(service); + + var result = await controller.AddUser(new User_DTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Updated Successfully"); + } + + [Fact] + public async Task AddUser_Failure_ReturnsBadRequestWithServiceError() + { + var service = new Mock(); + service.Setup(s => s.AddUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = "boom" }); + + var controller = NewController(service); + + var result = await controller.AddUser(new User_DTO(), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Error"); + response.Message.Should().Be("boom"); + } + + [Fact] + public async Task EditUser_NotFound_ReturnsUserNotFoundMessage() + { + var service = new Mock(); + service.Setup(s => s.EditUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("User not found"); + } + + [Fact] + public async Task EditUser_Found_ReturnsUpdatedSuccessfully() + { + var service = new Mock(); + service.Setup(s => s.EditUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType(); + } + + [Fact] + public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage() + { + var service = new Mock(); + service.Setup(s => s.DeleteUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.DeleteUser(new EditUser_DTO { Id = "missing" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("ID not matched!"); + } + + [Fact] + public async Task DeleteUser_Found_DelegatesAndReturnsOk() + { + var service = new Mock(); + service.Setup(s => s.DeleteUserAsync("5", It.IsAny())).ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(s => s.DeleteUserAsync("5", It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteCurrentUser_UsesClaimUserId() + { + var service = new Mock(); + service.Setup(s => s.DeleteCurrentUserAsync("77", It.IsAny())).Returns(Task.CompletedTask); + + var controller = NewController(service, "77"); + + var result = await controller.DeleteCurrentUser(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType(); + service.Verify(s => s.DeleteCurrentUserAsync("77", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserProfile_PreservesLowercaseAddedDateKey() + { + var service = new Mock(); + service.Setup(s => s.GetUserProfileAsync("77", It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = "77", Name = "Me", AddedDate = new DateTime(2026, 1, 1), Email = "me@x.com", Contact = "321" } + }); + + var controller = NewController(service, "77"); + + var result = await controller.UserProfile(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var json = Json(ok.Value); + json.Should().Contain("\"Id\":\"77\""); + json.Should().Contain("\"Name\":\"Me\""); + json.Should().Contain("\"addeddate\":\""); + json.Should().Contain("\"Email\":\"me@x.com\""); + json.Should().Contain("\"Contact\":\"321\""); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs new file mode 100644 index 0000000..8047ad7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs @@ -0,0 +1,195 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Moq; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class UserServiceTests +{ + private static UserService NewService( + Mock userData, + Mock email, + out Mock> store) + { + var (manager, s, _) = IdentityTestHelpers.CreateUserManager(); + store = s; + return new UserService(manager, userData.Object, email.Object); + } + + [Fact] + public async Task GetUsers_FormatsCreatedDate_AndHandlesNullDate() + { + var userData = new Mock(); + userData.Setup(u => u.GetAllForListAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", CreatedDate = new DateTime(2026, 1, 5) }, + new() { Id = "2", Name = "B", Email = "b@x.com", RoleName = "Tech", Status = "Active", CreatedDate = null } + }); + + var service = NewService(userData, new Mock(), out _); + + var users = (await service.GetUsersAsync(CancellationToken.None)).ToList(); + + users.Should().HaveCount(2); + users[0].Date.Should().Be("Jan 05 2026"); + users[0].RoleName.Should().Be("Admin"); + users[0].Status.Should().Be("Active"); + users[1].Date.Should().Be(""); + users[1].Name.Should().Be("B"); + } + + [Fact] + public async Task DeleteUser_NotFound_ReturnsFalseWithoutCascade() + { + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("missing", It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), out _); + + var found = await service.DeleteUserAsync("missing", CancellationToken.None); + + found.Should().BeFalse(); + userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteUser_Found_PerformsCascade() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + var found = await service.DeleteUserAsync("u1", CancellationToken.None); + + found.Should().BeTrue(); + userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteCurrentUser_SetsIsDeletedFlag() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + await service.DeleteCurrentUserAsync("u1", CancellationToken.None); + + user.IsDeleted.Should().BeTrue(); + userData.Verify(u => u.UpdateUserAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditUser_NotFound_ReturnsFalse() + { + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("x", It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), out _); + + var succeeded = await service.EditUserAsync(new EditUserRequestDTO { Id = "x", Email = "e@x.com", Name = "n", Role = "Admin" }, CancellationToken.None); + + succeeded.Should().BeFalse(); + } + + [Fact] + public async Task GetUserProfile_MapsCreatedDateToAddedDate() + { + var user = new UserProfileData + { + Id = "u1", + FirstName = "Alice", + CreatedDate = new DateTime(2026, 3, 4) + }; + var userData = new Mock(); + userData.Setup(u => u.GetProfileAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + var rows = await service.GetUserProfileAsync("u1", CancellationToken.None); + + rows.Should().HaveCount(1); + rows[0].AddedDate.Should().Be(new DateTime(2026, 3, 4)); + rows[0].Name.Should().Be("Alice"); + } + + [Fact] + public async Task AddUser_CreateSuccess_PersistsRoleAndEmailsLoginInformation() + { + var userData = new Mock(); + userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + var email = new Mock(); + + var service = NewService(userData, email, out var store); + store.As>() + .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + store.Setup(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); + + outcome.Success.Should().BeTrue(); + store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); + store.Verify(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + email.Verify( + e => e.SendEmailAsync( + "n@x.com", + "Login Information", + It.Is(body => ContainsStrongGeneratedPassword(body))), + Times.Once); + } + + [Fact] + public async Task AddUser_CreateFails_ReturnsFirstError() + { + var userData = new Mock(); + userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), out var store); + store.As>() + .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "duplicate" })); + + var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be("duplicate"); + } + + private static bool ContainsStrongGeneratedPassword(string body) + { + const string marker = "Your password is: "; + var start = body.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + return false; + + var password = body[(start + marker.Length)..] + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + + return password is + { + Length: 8 + } + && password.Any(char.IsUpper) + && password.Any(char.IsLower) + && password.Any(char.IsDigit) + && password.Any(character => !char.IsLetterOrDigit(character)); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index 2440734..e7652b8 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -4,12 +4,11 @@ using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; -using System.Text.Json; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -18,12 +17,7 @@ public class VendorControllerTests { private static VendorController NewController(Mock service) { - var controller = new VendorController( - service.Object, - db: null!, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = new VendorController(service.Object, Mock.Of>()); controller.ControllerContext = new ControllerContext { @@ -108,62 +102,216 @@ public class VendorControllerTests "42"), Times.Once); } - [Theory] - [InlineData(true, "Active Co", "Inactive Co")] - [InlineData(false, "Inactive Co", "Active Co")] - public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors( - bool isActive, - string expected, - string excluded) + [Fact] + public async Task GetFacets_DelegatesToServiceAndReturnsOk() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using var context = new ApplicationDbContext(options); - context.Vendors.AddRange( - new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, - new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); - await context.SaveChangesAsync(); + var service = new Mock(); + var facets = new VendorFacetsDTO + { + Companies = new[] { new VendorFacetCompanyDTO { Name = "Active Co" } }, + Trades = new[] { "Plumbing" }, + Locations = new[] { new VendorFacetLocationDTO { Label = "Chicago, IL" } }, + JobBuckets = new[] { new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" } } + }; + service.Setup(x => x.GetFacetsAsync(true, It.IsAny())) + .ReturnsAsync(facets); - var controller = new VendorController( - Mock.Of(), - context, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = NewController(service); - var result = await controller.GetFacets(isActive); - var payload = JsonSerializer.Serialize( - result.Should().BeOfType().Subject.Value); + var result = await controller.GetFacets(true); - payload.Should().Contain(expected); - payload.Should().NotContain(excluded); + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(facets); + service.Verify(x => x.GetFacetsAsync(true, It.IsAny()), Times.Once); } [Fact] - public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup() + public async Task GetDropdown_DelegatesToServiceAndReturnsOk() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using var context = new ApplicationDbContext(options); - context.Vendors.AddRange( - new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, - new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); - await context.SaveChangesAsync(); + var service = new Mock(); + var items = new[] + { + new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 } + }; + service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny())) + .ReturnsAsync(items); - var controller = new VendorController( - Mock.Of(), - context, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = NewController(service); - var result = await controller.GetFacets(); - var payload = JsonSerializer.Serialize( - result.Should().BeOfType().Subject.Value); + var result = await controller.GetDropdown("Plumbing", "60601"); - payload.Should().Contain("Active Co"); - payload.Should().Contain("Inactive Co"); + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(items); + } + + [Fact] + public async Task GetPortalToken_ReturnsTokenDtoFromService() + { + var service = new Mock(); + var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" }; + service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(token); + + var controller = NewController(service); + + var result = await controller.GetPortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(token); + } + + [Fact] + public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist() + { + var service = new Mock(); + service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny())) + .ReturnsAsync((VendorPortalTokenDTO?)null); + + var controller = NewController(service); + + var result = await controller.GetPortalToken(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RotatePortalToken_ReturnsRotatedToken() + { + var service = new Mock(); + var token = new VendorPortalTokenDTO { Token = "new-token" }; + service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(token); + + var controller = NewController(service); + + var result = await controller.RotatePortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(token); + } + + [Fact] + public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess() + { + var service = new Mock(); + service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.RevokePortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().NotBeNull(); + } + + [Fact] + public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist() + { + var service = new Mock(); + service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.RevokePortalToken(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_WhenDeactivationBlocked_ReturnsSanitizedConflictWithOpenWorkOrdersAndLogs() + { + var openWorkOrders = new List { new() { WorkOrderId = 7 } }; + var service = new Mock(); + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorDeactivationBlockedException("SECRET-internal-reason", openWorkOrders)); + + var logger = new Mock>(); + logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var controller = new VendorController(service.Object, logger.Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "Test")) + } + }; + + var result = await controller.Delete(5); + + var conflict = result.Should().BeOfType().Subject; + var value = conflict.Value!; + var message = (string)value.GetType().GetProperty("Message")!.GetValue(value)!; + message.Should().NotContain("SECRET"); + message.Should().Contain("reference"); + var returnedOpenWorkOrders = (List)value.GetType().GetProperty("OpenWorkOrders")!.GetValue(value)!; + returnedOpenWorkOrders.Should().HaveCount(1); + logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.Is(ex => ex.Message.Contains("SECRET")), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Delete_MapsDeactivationBlocked_ToConflict() + { + var service = new Mock(); + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List())); + + var controller = NewController(service); + + var result = await controller.Delete(5); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound() + { + var service = new Mock(); + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException()); + + var controller = NewController(service); + + var result = await controller.Delete(5); + + var nf = result.Should().BeOfType().Subject; + nf.Value.Should().BeOfType(); + } + + [Fact] + public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull() + { + var service = new Mock(); + service.Setup(x => x.GetVendorByIdAsync(99)) + .ReturnsAsync((VendorDTO?)null); + + var controller = NewController(service); + + var result = await controller.GetVendorById(99, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Update_UnauthenticatedUser_ReturnsUnauthorized() + { + var service = new Mock(); + var controller = new VendorController(service.Object, Mock.Of>()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() } + }; + + var result = await controller.Update(5, new EditVendor_DTO { Id = 5, CompanyName = "X" }); + + result.Should().BeOfType(); } } diff --git a/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs index d7d7bec..a6c6e68 100644 --- a/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -23,10 +25,9 @@ public class VendorOperationsControllerTests private static VendorOperationsController NewController(ApplicationDbContext context) { - var controller = new VendorOperationsController( - context, - new DispatchDataService(context), - TimeProvider.System); + var dataService = new VendorOperationsDataService(context, new DispatchDataService(context)); + var service = new VendorOperationsService(dataService, TimeProvider.System); + var controller = new VendorOperationsController(service); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext @@ -53,7 +54,7 @@ public class VendorOperationsControllerTests }); await context.SaveChangesAsync(); - var result = await NewController(context).GetNotifications(); + var result = await NewController(context).GetNotifications(CancellationToken.None); var value = ((OkObjectResult)result).Value!; var items = ((IEnumerable)Prop(value, "Items")).Cast().ToList(); @@ -83,7 +84,7 @@ public class VendorOperationsControllerTests }); await context.SaveChangesAsync(); - var result = await NewController(context).GetNotifications(); + var result = await NewController(context).GetNotifications(CancellationToken.None); var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast(); items.Should().BeEmpty(); @@ -132,7 +133,8 @@ public class VendorOperationsControllerTests DateTime.UtcNow.AddHours(2), DateTime.UtcNow.AddHours(4), site.Id, - "HVAC"); + "HVAC", + CancellationToken.None); var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast().ToList(); Prop(rows[0], "CompanyName").Should().Be("Preferred"); @@ -151,10 +153,11 @@ public class VendorOperationsControllerTests await context.SaveChangesAsync(); var result = await NewController(context).ReplaceSitePreferences(site.Id, - new List + new List { new() { VendorId = inactive.Id } - }); + }, + CancellationToken.None); result.Should().BeOfType(); context.SitePreferredVendors.Should().BeEmpty(); @@ -181,8 +184,8 @@ public class VendorOperationsControllerTests await context.SaveChangesAsync(); var controller = NewController(context); - var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null); - var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null); + var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null, CancellationToken.None); + var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null, CancellationToken.None); csv.ContentType.Should().Be("text/csv"); var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents); @@ -203,7 +206,8 @@ public class VendorOperationsControllerTests var result = await NewController(context).AssignVendor( workOrder.Id, - new VendorOperationsController.VendorAssignmentRequest { VendorId = vendor.Id }); + new VendorAssignmentRequest { VendorId = vendor.Id }, + CancellationToken.None); result.Should().BeOfType(); context.Dispatches.Should().BeEmpty(); @@ -247,14 +251,15 @@ public class VendorOperationsControllerTests var result = await NewController(context).AssignVendor( assignedWorkOrder.Id, - new VendorOperationsController.VendorAssignmentRequest + new VendorAssignmentRequest { VendorId = vendor.Id, ScheduledStart = assignedWorkOrder.ScheduledStart, ScheduledEnd = assignedWorkOrder.ScheduledEnd, EstimatedArrivalAt = eta, EtaManualOverride = true - }); + }, + CancellationToken.None); var value = ((OkObjectResult)result).Value!; Prop(value, "AvailabilityStatus").Should().Be("Unavailable"); @@ -299,12 +304,13 @@ public class VendorOperationsControllerTests var result = await NewController(context).AssignVendor( workOrder.Id, - new VendorOperationsController.VendorAssignmentRequest + new VendorAssignmentRequest { VendorId = replacementVendor.Id, ScheduledStart = workOrder.ScheduledStart, ScheduledEnd = workOrder.ScheduledEnd - }); + }, + CancellationToken.None); result.Should().BeOfType(); context.Dispatches.Should().ContainSingle(); diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs new file mode 100644 index 0000000..3060962 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -0,0 +1,317 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorPortalControllerTests +{ + private static VendorPortalController NewController(Mock service, string? token = "valid-token") + { + var controller = new VendorPortalController(service.Object, Mock.Of>()); + var context = new DefaultHttpContext(); + if (token != null) + context.Request.Headers["X-Vendor-Token"] = token; + controller.ControllerContext = new ControllerContext { HttpContext = context }; + return controller; + } + + private static readonly VendorPortalSession Session = new() + { + Id = 1, + CompanyName = "Test Co", + ContactName = "Sam", + Email = "sam@test.co", + Phone = "555-0100" + }; + + [Fact] + public async Task Session_MissingToken_ReturnsUnauthorized() + { + var service = new Mock(); + var controller = NewController(service, token: null); + + var result = await controller.Session(); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Session_InvalidToken_ReturnsUnauthorized() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync("bad", It.IsAny())) + .ReturnsAsync((VendorPortalSession?)null); + var controller = NewController(service, token: "bad"); + + var result = await controller.Session(); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Session_ValidToken_ReturnsSessionData() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync("valid-token", It.IsAny())) + .ReturnsAsync(Session); + var controller = NewController(service); + + var result = await controller.Session(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + var sessionData = envelope.Data as VendorPortalSession; + sessionData.Should().Be(Session); + } + + [Fact] + public async Task ListDispatches_DelegatesToServiceWithStatus() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var dispatches = new[] { new VendorDispatchSummaryDTO { Id = 10, Status = "Sent" } }; + service.Setup(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny())) + .ReturnsAsync(dispatches); + var controller = NewController(service); + + var result = await controller.ListDispatches("Sent"); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDispatch_NotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.GetDispatchDetailAsync(Session, 99, It.IsAny())) + .ReturnsAsync((VendorDispatchDetailDTO?)null); + var controller = NewController(service); + + var result = await controller.GetDispatch(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Accept_KeyNotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AcceptDispatchAsync(Session, 99, It.IsAny())) + .ThrowsAsync(new KeyNotFoundException()); + var controller = NewController(service); + + var result = await controller.Accept(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Accept_InvalidOperation_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot accept")); + var controller = NewController(service); + + var result = await controller.Accept(10); + + var bad = result.Should().BeOfType().Subject; + var resp = bad.Value.Should().BeOfType().Subject; + resp.Message.Should().NotContain("Cannot accept"); + resp.Message.Should().Contain("reference"); + } + + [Fact] + public async Task Accept_Success_ReturnsAcknowledgedResult() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var acceptResult = new AcceptDispatchResultDTO { Id = 10, Status = "Acknowledged" }; + service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny())) + .ReturnsAsync(acceptResult); + var controller = NewController(service); + + var result = await controller.Accept(10); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var acceptData = envelope.Data as AcceptDispatchResultDTO; + acceptData.Should().Be(acceptResult); + } + + [Fact] + public async Task ChangeStatus_ForbiddenTransition_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.ChangeStatusAsync(Session, 10, "Completed", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Transition not allowed")); + var controller = NewController(service); + + var result = await controller.ChangeStatus(10, new VendorPortalController.ChangeStatusRequest { Status = "Completed" }); + + var bad = result.Should().BeOfType().Subject; + var resp = bad.Value.Should().BeOfType().Subject; + resp.Message.Should().NotContain("Transition"); + resp.Message.Should().Contain("reference"); + } + + [Fact] + public async Task RequestCancel_Success_ReturnsSuccessWithMessage() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var controller = NewController(service); + + var result = await controller.RequestCancel(10, new VendorPortalController.RequestCancelRequest { Reason = "Parts delay" }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + envelope.Message.Should().Be("Cancel request sent to dispatcher"); + service.Verify(x => x.RequestCancelAsync(Session, 10, "Parts delay", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateChecklistItem_LockedDispatch_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.UpdateChecklistItemAsync(Session, 10, 5, true, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Dispatch is locked")); + var controller = NewController(service); + + var result = await controller.UpdateChecklistItem(10, 5, new VendorPortalController.ChecklistUpdateRequest { IsCompleted = true }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task AddSignoff_DuplicateSignoff_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AddSignoffAsync(Session, 10, "vendor", "Name", "sig", "drawn", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("A vendor signoff already exists")); + var controller = NewController(service); + + var result = await controller.AddSignoff(10, new VendorPortalController.SignoffRequest + { + SignoffType = "vendor", + Name = "Name", + Signature = "sig", + SignatureMethod = "drawn" + }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RequestUplift_NTEBelowCurrent_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Requested NTE must be greater than the current NTE")); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody { RequestedNTE = 100m }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RequestUplift_Success_ReturnsResult() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var upliftResult = new UpliftRequestResultDTO { Id = 77, Status = "Pending", RequiredTier = 1 }; + service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny())) + .ReturnsAsync(upliftResult); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody + { + RequestedNTE = 500m, + Reason = "Need more parts" + }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var upliftData = envelope.Data as UpliftRequestResultDTO; + upliftData.Should().Be(upliftResult); + } + + [Fact] + public async Task CancelUpliftRequest_NonPending_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot cancel a 'Approved' uplift request")); + var controller = NewController(service); + + var result = await controller.CancelUpliftRequest(10, 77); + + result.Should().BeOfType(); + } + + [Fact] + public async Task CancelUpliftRequest_Success_ReturnsCancelledStatus() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var cancelResult = new CancelUpliftResultDTO { Id = 77, Status = "Cancelled" }; + service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny())) + .ReturnsAsync(cancelResult); + var controller = NewController(service); + + var result = await controller.CancelUpliftRequest(10, 77); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var cancelData = envelope.Data as CancelUpliftResultDTO; + cancelData.Should().Be(cancelResult); + } + + [Fact] + public async Task AddComment_EmptyText_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AddCommentAsync(Session, 10, "", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Comment cannot be empty")); + var controller = NewController(service); + + var result = await controller.AddComment(10, new VendorPortalController.CommentRequest { CommentText = "" }); + + result.Should().BeOfType(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index be73fbb..fab68d5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -1,13 +1,20 @@ using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.Infrastructure; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -29,21 +36,29 @@ public sealed class VendorPortalDocumentTests : IDisposable private VendorPortalController NewController(ApplicationDbContext context) { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["VendorPortal:TokenLifetimeDays"] = "365", - ["SendGrid:ApiKey"] = "not-used" - }) - .Build(); - var environment = new Mock(); - environment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); - var controller = new VendorPortalController( - context, - new VendorPortalTokenService(context, configuration), - configuration, - new SendMessage(configuration), - environment.Object); + var vendorData = new VendorDataService(context); + var tokenService = new VendorPortalTokenService( + vendorData, + Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); + var documentData = new VendorDocumentDataService(context); + var storageEnvironment = new Mock(); + storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); + var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object); + + var service = new VendorPortalService( + tokenService, + new DispatchDataService(context), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + documentData, + storage, + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions())); + + var controller = new VendorPortalController(service, Mock.Of>()); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 315bd18..cc55139 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -2,10 +2,13 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using FluentValidation; +using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; using Xunit; @@ -14,7 +17,14 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorServiceTests { private static VendorService NewService(Mock data) => - new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); + new( + data.Object, + Mock.Of(), + Mock.Of(), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + new CreateVendorValidation(), + new UpdateVendorValidation(), + new WorkOrderVendorUpdateValidation()); [Fact] public async Task CreateVendor_PersistsCompleteProfile() diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index 39e02db..10ac872 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -1,13 +1,8 @@ using Api.SeaHavenIndustries.Controllers; -using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentAssertions; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; @@ -20,51 +15,14 @@ namespace Api.SeaHavenIndustries.Tests; public class WorkOrderControllerSearchTests { - private static ApplicationDbContext NewContext() + private static WorkOrderBoardController NewController(IWorkOrderAdvancedSearchService advancedSearch) { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - return new ApplicationDbContext(options); - } - - private static UserManager MockUserManager() - { - var store = new Mock>(); - return new Mock>( - store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object; - } - - private static WorkOrderController NewController( - ApplicationDbContext ctx, - IWorkOrderAdvancedSearchService advancedSearch) - { - var controller = new WorkOrderController( - MockUserManager(), - Mock.Of(), - Mock.Of(), - Mock.Of(), - Mock.Of(), - Mock.Of(), - Mock.Of(), - Mock.Of(), - Mock.Of(), - ctx, - Mock.Of(), - Mock.Of(), - null!, - null!, - Mock.Of(), + var controller = new WorkOrderBoardController( Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), - advancedSearch, - Mock.Of(), - Mock.Of(), - Mock.Of(), - Mock.Of()); - + advancedSearch); controller.ControllerContext = new ControllerContext { @@ -88,10 +46,9 @@ public class WorkOrderControllerSearchTests [Fact] public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() { - using var ctx = NewContext(); var advancedSearch = new WorkOrderAdvancedSearchService( Mock.Of()); - var controller = NewController(ctx, advancedSearch); + var controller = NewController(advancedSearch); var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs new file mode 100644 index 0000000..a32815f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs @@ -0,0 +1,104 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderDataServiceBatchingTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task SeedAsync(ApplicationDbContext context) + { + context.Locations.Add(new Locations { Id = 100, Name = "Site A", Address1 = "1 Main St" }); + context.workOrders.AddRange( + new WorkOrder { Id = 1, InternalWONumber = "WO-1", WorkerOrderTitle = "First", LocationId = 100 }, + new WorkOrder { Id = 2, InternalWONumber = "WO-2", WorkerOrderTitle = "Second", LocationId = 100 }, + new WorkOrder { Id = 3, InternalWONumber = "WO-3", WorkerOrderTitle = "Third", LocationId = 100 }, + new WorkOrder { Id = 4, InternalWONumber = "WO-T", WorkerOrderTitle = "Template", LocationId = 100, istemplate = true }); + await context.SaveChangesAsync(); + } + + [Fact] + public async Task GetByRequestedIdsWithDetailsAsync_ReturnsResultsInInputOrder() + { + await using var context = NewContext(); + await SeedAsync(context); + var service = new WorkOrderDataService(context); + + var result = await service.GetByRequestedIdsWithDetailsAsync( + new[] { 3, 1, 2 }, + CancellationToken.None); + + result.Select(w => w.Id).Should().Equal(new[] { 3, 1, 2 }); + } + + [Fact] + public async Task GetByRequestedIdsWithDetailsAsync_DeduplicatesIdsAndSkipsMissing() + { + await using var context = NewContext(); + await SeedAsync(context); + var service = new WorkOrderDataService(context); + + var result = await service.GetByRequestedIdsWithDetailsAsync( + new[] { 2, 2, 999, 1 }, + CancellationToken.None); + + result.Select(w => w.Id).Should().Equal(new[] { 2, 1 }); + } + + [Fact] + public async Task GetByRequestedIdsWithDetailsAsync_ExcludesTemplatesAndEagerLoadsLocation() + { + await using var context = NewContext(); + await SeedAsync(context); + var service = new WorkOrderDataService(context); + + var result = await service.GetByRequestedIdsWithDetailsAsync( + new[] { 1, 4 }, + CancellationToken.None); + + result.Select(w => w.Id).Should().Equal(new[] { 1 }); + result.Single().Locations.Should().NotBeNull(); + result.Single().Locations!.Name.Should().Be("Site A"); + } + + [Fact] + public async Task AddAuditLogsAsync_PersistsAllRowsInSingleSave() + { + await using var context = NewContext(); + await SeedAsync(context); + var service = new WorkOrderDataService(context); + + await service.AddAuditLogsAsync(new[] + { + new WorkOrderAuditLog { WorkOrderId = 1, FieldName = "Dispatch", Action = "Dispatch" }, + new WorkOrderAuditLog { WorkOrderId = 2, FieldName = "Dispatch", Action = "Dispatch" } + }); + + context.WorkOrderAuditLogs.Should().HaveCount(2); + } + + [Fact] + public async Task GetByIdsOrderedAsync_PreservesInputOrderDeduplicatesAndSkipsMissing() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { Id = 7, CompanyName = "V7", Email = "v7@x.io" }, + new Vendor { Id = 8, CompanyName = "V8", Email = "v8@x.io" }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var result = await service.GetByIdsOrderedAsync(new[] { 8, 8, 7, 999 }, CancellationToken.None); + + result.Select(v => v.Id).Should().Equal(new[] { 8, 7 }); + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs new file mode 100644 index 0000000..7fd4260 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -0,0 +1,206 @@ +using Api.SeaHavenIndustries.Controllers; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// Framework-backed route contract tests that prove the public endpoint set exposed by the +/// WorkOrder URL space (api/WorkOrder, api/workorders) is preserved exactly after splitting the +/// original godfile WorkOrderController into cohesive controllers, and that no verb+route +/// combination is registered more than once by different actions. +/// +/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the +/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder, +/// GetWorkorderById) and the two shared controller-level base routes. +/// +public class WorkOrderRouteContractTests +{ + private readonly ITestOutputHelper _output; + + private static readonly HashSet WorkOrderFamilyControllerTypes = new() + { + typeof(WorkOrderController), + typeof(WorkOrderBoardController), + typeof(WorkOrderDetailController), + typeof(WorkOrderCompletionController), + typeof(WorkOrderMediaController), + typeof(WorkOrderDispatchController), + }; + + /// + /// Baseline public endpoint set (verb + action-relative route) that the original single + /// WorkOrderController exposed. Every action is reachable under both api/WorkOrder and + /// api/workorders; that base-route duplication is collapsed here, so this is the distinct + /// action-relative contract. 45 routes come from 43 actions (Editworkorder and + /// GetWorkorderById each bind two routes). + /// + private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) + { + "DELETE DeleteWorkorder", + "DELETE completion-templates/{id:int}", + "DELETE {id:int}/media/{mediaId:int}", + "GET GetComments", + "GET GetCommentsByWorkorderId", + "GET GetDispatch/{id}", + "GET GetDispatches/{workOrderId}", + "GET GetFilteredWorkorder", + "GET GetFilteredWorkorder2", + "GET GetWorkOrderList", + "GET GetWorkorderById", + "GET Getworkorders", + "GET GetworkordersDD", + "GET board", + "GET board/search", + "GET completion-templates", + "GET completion-templates/{id:int}", + "GET lookups/dispatchers", + "GET {id:int}", + "GET {id:int}/audit", + "GET {id:int}/comments", + "GET {id:int}/detail", + "GET {id:int}/media", + "PATCH {id:int}/board", + "POST AddChecklistItem", + "POST AddComment", + "POST AddCommentJson", + "POST AddDispatchComment", + "POST AddDispatchSignoff", + "POST AddWorkorder", + "POST ChangeAssignment", + "POST ChangeStatus", + "POST DispatchToVendor", + "POST EditWorkorder", + "POST UpdateChecklistItem", + "POST UpdateDispatch", + "POST VerifyDispatch", + "POST board", + "POST completion-templates", + "POST {id:int}/cancel", + "POST {id:int}/comments", + "POST {id:int}/completion-doc", + "POST {id:int}/media", + "PUT completion-templates/{id:int}", + "PUT {id:int}", + }; + + public WorkOrderRouteContractTests(ITestOutputHelper output) + { + _output = output; + } + + private static IReadOnlyList BuildActionDescriptors() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore() + .AddApplicationPart(typeof(WorkOrderController).Assembly); + + using var provider = services.BuildServiceProvider(); + var actionProvider = provider.GetRequiredService(); + return actionProvider.ActionDescriptors.Items; + } + + private static IReadOnlyList<(string Verb, string FullTemplate, string Controller, string Action)> FullRoutesFor( + Func predicate) + { + var tuples = new List<(string Verb, string FullTemplate, string Controller, string Action)>(); + foreach (var action in BuildActionDescriptors().Where(predicate)) + { + var cad = action as ControllerActionDescriptor; + var template = cad?.AttributeRouteInfo?.Template?.Trim('/'); + if (string.IsNullOrEmpty(template)) + continue; + + var methods = action.ActionConstraints + .OfType() + .SelectMany(c => c.HttpMethods) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var method in methods) + tuples.Add((method.ToUpperInvariant(), template, cad!.ControllerName, cad.ActionName)); + } + + return tuples; + } + + private static string Normalize(string fullTemplate) + { + foreach (var prefix in new[] { "api/WorkOrder/", "api/workorders/" }) + { + if (fullTemplate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return fullTemplate[prefix.Length..]; + } + foreach (var exact in new[] { "api/WorkOrder", "api/workorders" }) + { + if (fullTemplate.Equals(exact, StringComparison.OrdinalIgnoreCase)) + return string.Empty; + } + return fullTemplate; + } + + [Fact] + public void WorkOrder_Family_Preserves_Expected_Public_Endpoint_Set() + { + var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad + && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) + .Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}") + .ToHashSet(StringComparer.Ordinal); + + Dump(actual); + + var missing = ExpectedWorkOrderEndpoints.Except(actual).OrderBy(x => x).ToList(); + var unexpected = actual.Except(ExpectedWorkOrderEndpoints).OrderBy(x => x).ToList(); + + missing.Should().BeEmpty("every pre-split WorkOrder endpoint must still be reachable"); + unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints"); + actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count, + "the distinct action-relative endpoint count must match the baseline"); + } + + [Fact] + public void WorkOrder_Family_Has_No_Duplicate_Verb_And_Route_From_Different_Actions() + { + // Duplicate detection is done at the FULL route-template level. Each action legitimately + // resolves under both base routes (api/WorkOrder and api/workorders); those are distinct + // full templates. A true collision is the SAME full template produced by two actions, + // which would cause an AmbiguousMatchException at request time. + var collisions = FullRoutesFor(a => a is ControllerActionDescriptor cad + && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) + .GroupBy(t => (t.Verb, t.FullTemplate)) + .Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1) + .Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}") + .ToList(); + + collisions.Should().BeEmpty( + "no two actions in the WorkOrder family may register the same verb+full-route"); + } + + [Fact] + public void WorkOrder_Url_Space_Has_No_Duplicate_Verb_And_Route_From_Different_Actions() + { + var collisions = FullRoutesFor(_ => true) + .Where(t => t.FullTemplate.StartsWith("api/WorkOrder", StringComparison.OrdinalIgnoreCase) + || t.FullTemplate.StartsWith("api/workorders", StringComparison.OrdinalIgnoreCase)) + .GroupBy(t => (t.Verb, t.FullTemplate)) + .Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1) + .Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}") + .ToList(); + + collisions.Should().BeEmpty( + "no two controllers sharing api/WorkOrder or api/workorders may register the same verb+full-route"); + } + + private void Dump(IEnumerable endpoints) + { + _output.WriteLine("WorkOrder-family public endpoints (verb + action-relative route):"); + foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal)) + _output.WriteLine(" " + endpoint); + } +} diff --git a/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj b/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj index b1c8fc5..9de9eaf 100644 --- a/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj +++ b/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 013cbfe..12fd8fa 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -1,14 +1,12 @@ -using Data.SeaHavenIndustries; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.DTOs; using Api.SeaHavenIndustries.Helper; -using Microsoft.EntityFrameworkCore; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { @@ -16,73 +14,54 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/Authentication")] public class AuthenticationController : Controller { - private readonly UserManager _userManager; - private readonly IConfiguration _configuration; - private readonly ApplicationDbContext _db; - private readonly SendMessage _sendMessage; + private readonly IAuthenticationService _authenticationService; + private readonly ILogger _logger; - public AuthenticationController(UserManager userManager, IConfiguration configuration, ApplicationDbContext db, SendMessage sendMessage) + public AuthenticationController(IAuthenticationService authenticationService, ILogger logger) { - _userManager = userManager; - _configuration = configuration; - _db = db; - _sendMessage = sendMessage; + _authenticationService = authenticationService; + _logger = logger; } [AllowAnonymous] [HttpPost] [Route("login")] - public async Task Login([FromBody] LoginModel model) + public async Task Login([FromBody] LoginModel model, CancellationToken cancellationToken) { try { - var user = await _userManager.FindByNameAsync(model.Username ?? ""); - if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, model.Password ?? "")) + var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken); + if (result != null) { - // Standard login without 2FA - var userRoles = await _userManager.GetRolesAsync(user); - var authClaims = new List - { - new Claim(ClaimTypes.Name, user.UserName ?? ""), - new Claim(ClaimTypes.NameIdentifier, user.Id), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) - }; - foreach (var userRole in userRoles) - { - authClaims.Add(new Claim(ClaimTypes.Role, userRole)); - } - var token = GetToken(authClaims); return Ok(new { - token = new JwtSecurityTokenHandler().WriteToken(token), - expiration = token.ValidTo, - email = user.Email, - userRoles = userRoles.FirstOrDefault(), - phoneNumber = user.PhoneNumber, - fullname = user.FirstName + " " + user.LastName, - id = user.Id + token = result.Token, + expiration = result.Expiration, + email = result.Email, + userRoles = result.UserRole, + phoneNumber = result.PhoneNumber, + fullname = result.Fullname, + id = result.Id }); } - // Invalid credentials return Unauthorized(); } catch (Exception ex) { - return StatusCode(500, ex.Message); + return StatusCode(500, _logger.Sanitize(ex)); } - + } [Route("ChangePassword")] [HttpPost] - public async Task ChangePassword(ChangePasswords usermodel) + public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await _userManager.FindByIdAsync(userid); - var result = await _userManager.ChangePasswordAsync(user, usermodel.Currentpassword ?? "", usermodel.Confirmpassword ?? ""); - if (result.Succeeded) + var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken); + if (succeeded) { return Ok(new Response { Status = "Success ", Message = "Password successfully changed" }); } @@ -90,49 +69,36 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Old Password is incorrect" }); } - private JwtSecurityToken GetToken(List authClaims) - { - var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"] ?? "")); - var token = new JwtSecurityToken( - issuer: _configuration["JWT:ValidIssuer"], - audience: _configuration["JWT:ValidAudience"], - expires: DateTime.Now.AddDays(10), - claims: authClaims, - signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) - ); - return token; - } - [HttpPost] [Route("UpdateProfile")] - public async Task UserProfileUpdate([FromForm] User_DTO model) + public async Task UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken) { try { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - ApplicationUser appuser = _db.Users.AsNoTracking().Where(u => u.Id == userid).FirstOrDefault() ?? new ApplicationUser(); - appuser.FirstName = model.Name; - appuser.Email = model.Email; - appuser.Contact = model.Contact; - _db.Users.Update(appuser); - await _db.SaveChangesAsync(); + var dto = new UpdateProfileRequestDTO + { + Name = model.Name, + Email = model.Email, + Contact = model.Contact + }; + var data = await _authenticationService.UpdateProfileAsync(userid, dto, cancellationToken); return Ok(new DataResponse { Message = "Introduction Updated Successfully", Status = "200", - Data = _db.Users.Where(u => u.Id == userid).Select(s => new + Data = data == null ? null : new { - s.FirstName, - //s.Location, - s.Email, - s.Contact - }).FirstOrDefault() + data.FirstName, + data.Email, + data.Contact + } }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -141,25 +107,11 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("ForgetPassword")] - public async Task ForgetPassword(string Email) + public async Task ForgetPassword(string Email, CancellationToken cancellationToken) { - var user = await _db.Users.Where(u => u.Email.ToLower().Trim() == Email.ToLower().ToLower()).FirstOrDefaultAsync(); - if (user != null) + var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken); + if (found) { - if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == Email.ToLower().Trim()).Any()) - { - _db.ForgetPasswordCodes.Remove(_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == Email.ToLower().Trim()).FirstOrDefault()); - _db.SaveChanges(); - } - ForgetPasswordCode forgetPasswordCode = new ForgetPasswordCode(); - forgetPasswordCode.Email = user.Email ?? ""; - forgetPasswordCode.UserId = user.Id; - forgetPasswordCode.Code = GenerateRandomNo(); - - _db.ForgetPasswordCodes.Add(forgetPasswordCode); - _db.SaveChanges(); - string body = $"Your Password Reset Code is: " + forgetPasswordCode.Code; - await _sendMessage.SendEMail(user.Email, "Forget Password Request.", body); return Ok(new Response { Status = "Success ", Message = "Please check your email for code" }); } else @@ -171,11 +123,11 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("VerificationCode")] - public async Task VerificationCode(string code) + public async Task VerificationCode(string code, CancellationToken cancellationToken) { try { - if (await _db.ForgetPasswordCodes.Where(u => u.Code == code).AnyAsync()) + if (await _authenticationService.VerifyCodeAsync(code, cancellationToken)) { return Ok(new Response { Status = "Success ", Message = "Code Matched" }); @@ -187,7 +139,7 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = "Error: " + ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -195,16 +147,12 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("ResetPassword")] - public async Task ResetPassword(ForgetPassword_Dto fpdto) + public async Task ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken) { try { - if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim() && u.Code == fpdto.Code.Trim()).Any()) + if (await _authenticationService.ResetPasswordAsync(fpdto.Email, fpdto.Code, fpdto.Password, cancellationToken)) { - var GetUser = _db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim()).FirstOrDefault(); - var user = await _userManager.FindByIdAsync(GetUser.UserId); - var token = await _userManager.GeneratePasswordResetTokenAsync(user); - var result = await _userManager.ResetPasswordAsync(user, token, fpdto.Password); return Ok(new Response { Status = "Success ", Message = "password changed" }); } else @@ -215,16 +163,10 @@ namespace Api.SeaHavenIndustries.Controllers catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = "Error: " + ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } - - private Random _random = new Random(); - private string GenerateRandomNo() - { - return _random.Next(0, 999999).ToString("D6"); - } #endregion } } diff --git a/Api.SeaHavenIndustries/Controllers/CalendarController.cs b/Api.SeaHavenIndustries/Controllers/CalendarController.cs index 2d7f85d..b54b0b7 100644 --- a/Api.SeaHavenIndustries/Controllers/CalendarController.cs +++ b/Api.SeaHavenIndustries/Controllers/CalendarController.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -16,13 +14,13 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/Calendar")] public class CalendarController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; + private readonly ICalendarService _calendarService; + private readonly ILogger _logger; - public CalendarController(UserManager userManager, ApplicationDbContext db) + public CalendarController(ICalendarService calendarService, ILogger logger) { - _userManager = userManager; - _db = db; + _calendarService = calendarService; + _logger = logger; } // =================================================================================== @@ -30,37 +28,17 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpPost] [Route("AddEvent")] - public async Task AddEvent(EditEvent_DTO eventDto) + public async Task AddEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken) { try { - var model = new Events - { - Title = eventDto.Title, - Description = eventDto.Description, - Location = eventDto.Location, - RecordID = eventDto.RecordID, - RecordName = eventDto.RecordName, - RecordTargetType = eventDto.RecordTargetType, - ReminderMinutes = eventDto.ReminderMinutes, - EventColor = eventDto.EventColor, - StartDate = eventDto.StartDate, - StartTime = eventDto.StartTime, - EndDate = eventDto.EndDate, - EndTime = eventDto.EndTime, - AllDay = eventDto.AllDay, - CreatedDate = DateTime.Now, - IsDeleted = false - }; - - _db.Events.Add(model); - await _db.SaveChangesAsync(); + await _calendarService.CreateEventAsync(MapToInput(eventDto), cancellationToken); return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -69,7 +47,7 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpPost] [Route("EditEvent")] - public async Task EditEvent(EditEvent_DTO eventDto) + public async Task EditEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken) { try { @@ -78,35 +56,17 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" }); } - var model = await _db.Events.FindAsync(eventDto.Id.Value); - if (model == null || model.IsDeleted == true) + var updated = await _calendarService.UpdateEventAsync(MapToInput(eventDto), cancellationToken); + if (!updated) { return NotFound(new Response { Status = "Error", Message = "Event not found" }); } - model.Title = eventDto.Title; - model.Description = eventDto.Description; - model.Location = eventDto.Location; - model.RecordID = eventDto.RecordID; - model.RecordName = eventDto.RecordName; - model.RecordTargetType = eventDto.RecordTargetType; - model.ReminderMinutes = eventDto.ReminderMinutes; - model.EventColor = eventDto.EventColor; - model.StartDate = eventDto.StartDate; - model.StartTime = eventDto.StartTime; - model.EndDate = eventDto.EndDate; - model.EndTime = eventDto.EndTime; - model.AllDay = eventDto.AllDay; - model.LastModificationTime = DateTime.Now; - - _db.Events.Update(model); - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Event Updated Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -115,44 +75,22 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpGet] [Route("GetEventById/{id}")] - public async Task GetEventById(int id) + public async Task GetEventById(int id, CancellationToken cancellationToken) { try { - var model = await _db.Events - .Where(e => e.Id == id && e.IsDeleted != true) - .FirstOrDefaultAsync(); + var dto = await _calendarService.GetEventByIdAsync(id, cancellationToken); - if (model == null) + if (dto == null) { return NotFound(new Response { Status = "Error", Message = "Event not found" }); } - var dto = new Event_DTO - { - Id = model.Id, - Title = model.Title, - Description = model.Description, - Location = model.Location, - RecordID = model.RecordID, - RecordName = model.RecordName, - RecordTargetType = model.RecordTargetType, - ReminderMinutes = model.ReminderMinutes, - EventColor = model.EventColor, - StartDate = model.StartDate, - StartTime = model.StartTime, - EndDate = model.EndDate, - EndTime = model.EndTime, - AllDay = model.AllDay, - CreatedDate = model.CreatedDate, - createdby = model.createdby - }; - - return Ok(new DataResponse { Message = "Success", Status = "200", Data = dto }); + return Ok(new DataResponse { Message = "Success", Status = "200", Data = MapToResponse(dto) }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -161,28 +99,21 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpDelete] [Route("DeleteEvent/{id}")] - public async Task DeleteEvent(int id) + public async Task DeleteEvent(int id, CancellationToken cancellationToken) { try { - var model = await _db.Events.FindAsync(id); - if (model == null) + var deleted = await _calendarService.DeleteEventAsync(id, cancellationToken); + if (!deleted) { return NotFound(new Response { Status = "Error", Message = "Event not found" }); } - // Soft delete - model.IsDeleted = true; - model.DeletionTime = DateTime.Now; - - _db.Events.Update(model); - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Event Deleted Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -191,63 +122,24 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpGet] [Route("GetEventList")] - public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetEventList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { try { - var query = _db.Events.Where(e => e.IsDeleted != true); + var result = await _calendarService.GetEventListAsync(search, page, pageSize, cancellationToken); - // Apply search filter if provided - if (!string.IsNullOrWhiteSpace(search)) - { - var searchLower = search.ToLower(); - query = query.Where(e => - e.Title.ToLower().Contains(searchLower) || - (e.Description != null && e.Description.ToLower().Contains(searchLower)) || - (e.Location != null && e.Location.ToLower().Contains(searchLower)) - ); - } - - // IMPORTANT: Calculate totalCount AFTER applying filters - var totalCount = query.Count(); - - // Apply pagination - var events = query - .OrderByDescending(e => e.StartDate) - .ThenByDescending(e => e.CreatedDate) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(e => new Event_DTO - { - Id = e.Id, - Title = e.Title, - Description = e.Description, - Location = e.Location, - RecordID = e.RecordID, - RecordName = e.RecordName, - RecordTargetType = e.RecordTargetType, - ReminderMinutes = e.ReminderMinutes, - EventColor = e.EventColor, - StartDate = e.StartDate, - StartTime = e.StartTime, - EndDate = e.EndDate, - EndTime = e.EndTime, - AllDay = e.AllDay, - CreatedDate = e.CreatedDate, - createdby = e.createdby - }) - .ToList(); + var events = result.Rows.Select(MapToResponse).ToList(); return Ok(new DataResponse { Message = "Success", Status = "200", - Data = new { rows = events, totalCount } + Data = new { rows = events, totalCount = result.TotalCount } }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -256,51 +148,62 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpGet] [Route("GetEventsForCalendar")] - public IActionResult GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end) + public async Task GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end, CancellationToken cancellationToken) { try { - var query = _db.Events.Where(e => e.IsDeleted != true); + var events = await _calendarService.GetEventsForCalendarAsync(start, end, cancellationToken); - // Filter by date range if provided - if (start.HasValue && end.HasValue) - { - query = query.Where(e => - (e.StartDate >= start.Value && e.StartDate <= end.Value) || - (e.EndDate >= start.Value && e.EndDate <= end.Value) || - (e.StartDate <= start.Value && e.EndDate >= end.Value) - ); - } - - var events = query - .Select(e => new Event_DTO - { - Id = e.Id, - Title = e.Title, - Description = e.Description, - Location = e.Location, - RecordID = e.RecordID, - RecordName = e.RecordName, - RecordTargetType = e.RecordTargetType, - ReminderMinutes = e.ReminderMinutes, - EventColor = e.EventColor, - StartDate = e.StartDate, - StartTime = e.StartTime, - EndDate = e.EndDate, - EndTime = e.EndTime, - AllDay = e.AllDay, - CreatedDate = e.CreatedDate, - createdby = e.createdby - }) - .ToList(); - - return Ok(new DataResponse { Message = "Success", Status = "200", Data = events }); + return Ok(new DataResponse { Message = "Success", Status = "200", Data = events.Select(MapToResponse).ToList() }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } + + private static EditEventInput MapToInput(EditEvent_DTO eventDto) + { + return new EditEventInput + { + Id = eventDto.Id, + Title = eventDto.Title, + Description = eventDto.Description, + Location = eventDto.Location, + RecordID = eventDto.RecordID, + RecordName = eventDto.RecordName, + RecordTargetType = eventDto.RecordTargetType, + ReminderMinutes = eventDto.ReminderMinutes, + EventColor = eventDto.EventColor, + StartDate = eventDto.StartDate, + StartTime = eventDto.StartTime, + EndDate = eventDto.EndDate, + EndTime = eventDto.EndTime, + AllDay = eventDto.AllDay + }; + } + + private static Event_DTO MapToResponse(EventDTO dto) + { + return new Event_DTO + { + Id = dto.Id, + Title = dto.Title, + Description = dto.Description, + Location = dto.Location, + RecordID = dto.RecordID, + RecordName = dto.RecordName, + RecordTargetType = dto.RecordTargetType, + ReminderMinutes = dto.ReminderMinutes, + EventColor = dto.EventColor, + StartDate = dto.StartDate, + StartTime = dto.StartTime, + EndDate = dto.EndDate, + EndTime = dto.EndTime, + AllDay = dto.AllDay, + CreatedDate = dto.CreatedDate, + createdby = dto.createdby + }; + } } } - diff --git a/Api.SeaHavenIndustries/Controllers/CommonController.cs b/Api.SeaHavenIndustries/Controllers/CommonController.cs index 2a44899..f1624b4 100644 --- a/Api.SeaHavenIndustries/Controllers/CommonController.cs +++ b/Api.SeaHavenIndustries/Controllers/CommonController.cs @@ -1,136 +1,101 @@ using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.Helper.ViewModel; -using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { [Authorize] [ApiController] [Route("[controller]")] - + public class CommonController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - public CommonController(UserManager userManager, ApplicationDbContext db) + private readonly ICommonService _commonService; + private readonly ILogger _logger; + + public CommonController(ICommonService commonService, ILogger logger) { - _userManager = userManager; - _db = db; + _commonService = commonService; + _logger = logger; } [HttpGet] [Route("GetUsers")] - public async Task GetUsers(string? search = "", int page = 1, int pageSize = 10) + public async Task GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { try { - var allUsersQuery = _db.Users - .Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower())) - .OrderByDescending(d => d.Id); + var result = await _commonService.GetUsersPagedAsync(search, page, pageSize, cancellationToken); - var totalCount = allUsersQuery.Count(); - - var paginatedUsers = allUsersQuery - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToList(); - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - var filteredUsers = new List(); - - foreach (var user in paginatedUsers) + var filteredUsers = result.Users.Select(u => new UserRoleViewModel { - var roles = await _userManager.GetRolesAsync(user); - if (!roles.Contains("Admin")) - { - filteredUsers.Add(new UserRoleViewModel - { - RoleName = roles.Count()>0?roles.First():"", - Email = user.Email, - UserName = user.FirstName, - Date = user.CreatedDate?.Date.ToString(), - Status = user.UniqueName, - Id = user.Id - }); - } - } - // Create a view model containing the paged items and pagination metadata + RoleName = u.RoleName, + Email = u.Email, + UserName = u.UserName, + Date = u.Date, + Status = u.Status, + Id = u.Id + }).ToList(); + var viewModel = new Pagination_DTO { Data = filteredUsers, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages + PageNumber = result.PageNumber, + PageSize = result.PageSize, + TotalCount = result.TotalCount, + TotalPages = result.TotalPages }; return Ok(viewModel); } catch (Exception ex) { - return BadRequest(new { Status = "Error", Message = ex.Message }); + return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet] [Route("GetRoles")] - public async Task GetRoles() + public async Task GetRoles(CancellationToken cancellationToken) { try { - var data = await _db.Roles.Select(s => new {Id=s.Name,s.Name}).ToListAsync(); - + var data = await _commonService.GetRolesAsync(cancellationToken); + return Ok(data); } catch (Exception ex) { - return BadRequest(new { Status = "Error", Message = ex.Message }); + return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet] [Route("GetSitesName")] - public async Task GetSiteName() + public async Task GetSiteName(CancellationToken cancellationToken) { - var data =await _db.Locations.Select(n=> new - { - n.Id, - n.Name, - n.Title - }).ToListAsync(); + var data = await _commonService.GetSiteNamesAsync(cancellationToken); return Ok(data); } [HttpGet] [Route("GetContacts")] - public async Task GetContacts() + public async Task GetContacts(CancellationToken cancellationToken) { - var data =await _db.Contacts.Select(n=> new - { - n.Id, - Name = (n.FirstName + " " + n.LastName).Trim(), - email=n.Email, - phone=n.PhoneNumber - }).ToListAsync(); + var data = await _commonService.GetContactsAsync(cancellationToken); return Ok(data); } [HttpGet] [Route("GetCategories")] - public async Task GetCategories() + public async Task GetCategories(CancellationToken cancellationToken) { - var data = await _db.Categories.Select(s=> new - { - s.Id, - s.Name - }).ToListAsync(); + var data = await _commonService.GetCategoriesAsync(cancellationToken); return Ok(data); - } + } } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index b548af6..6955cc2 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -1,7 +1,6 @@ -using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -10,29 +9,24 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/[controller]")] public class DashboardController : Controller { - private readonly ApplicationDbContext _db; + private readonly IDashboardService _dashboardService; - public DashboardController(ApplicationDbContext db) + public DashboardController(IDashboardService dashboardService) { - _db = db; + _dashboardService = dashboardService; } [HttpGet("Stats")] - public async Task GetStats() + public async Task GetStats(CancellationToken cancellationToken) { - var query = _db.workOrders.Where(w => w.istemplate != true); - - var total = await query.CountAsync(); - var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold"); - var notDispatched = await query.CountAsync(w => (w.Status == "Open") && (w.AssignTo == null || w.AssignTo == "")); - var completed = await query.CountAsync(w => w.Status == "Done"); + var stats = await _dashboardService.GetStatsAsync(cancellationToken); return Ok(new { - total, - open, - notDispatched, - completed + total = stats.Total, + open = stats.Open, + notDispatched = stats.NotDispatched, + completed = stats.Completed }); } } diff --git a/Api.SeaHavenIndustries/Controllers/DispatchController.cs b/Api.SeaHavenIndustries/Controllers/DispatchController.cs index 530be20..f114a4a 100644 --- a/Api.SeaHavenIndustries/Controllers/DispatchController.cs +++ b/Api.SeaHavenIndustries/Controllers/DispatchController.cs @@ -1,9 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using SeaHaven.Services.Interfaces; -using SeaHaven.Services.DTOs; namespace Api.SeaHavenIndustries.Controllers { @@ -14,12 +12,10 @@ namespace Api.SeaHavenIndustries.Controllers public class DispatchController : Controller { private readonly IDispatchService _dispatchService; - private readonly ApplicationDbContext _db; // Keep for complex queries - public DispatchController(IDispatchService dispatchService, ApplicationDbContext db) + public DispatchController(IDispatchService dispatchService) { _dispatchService = dispatchService; - _db = db; } [HttpGet] @@ -32,317 +28,29 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] string? sortBy = "dispatchedAt", [FromQuery] string? sortDir = "desc", [FromQuery] int page = 1, - [FromQuery] int pageSize = 25) + [FromQuery] int pageSize = 25, + CancellationToken cancellationToken = default) { - var q = _db.Dispatches - .Include(d => d.Vendor) - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .Where(d => d.IsDeleted == null || d.IsDeleted == false); - - if (!string.IsNullOrWhiteSpace(search)) - { - var s = search.ToLower(); - q = q.Where(d => - (d.DispatchNumber ?? "").ToLower().Contains(s) || - (d.PONumber ?? "").ToLower().Contains(s) || - (d.Vendor != null && (d.Vendor.CompanyName ?? "").ToLower().Contains(s)) || - (d.WorkOrder != null && (d.WorkOrder.InternalWONumber ?? "").ToLower().Contains(s)) || - (d.WorkOrder != null && (d.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(s)) || - (d.WorkOrder != null && d.WorkOrder.Locations != null && (d.WorkOrder.Locations.Name ?? "").ToLower().Contains(s)) - ); - } - - if (!string.IsNullOrWhiteSpace(status)) - q = q.Where(d => d.Status == status); - - if (vendorId.HasValue) - q = q.Where(d => d.VendorId == vendorId.Value); - - if (dateFrom.HasValue) - q = q.Where(d => d.DispatchedAt >= dateFrom.Value); - - if (dateTo.HasValue) - { - var end = dateTo.Value.Date.AddDays(1); - q = q.Where(d => d.DispatchedAt < end); - } - - var total = await q.CountAsync(); - - var dir = (sortDir ?? "desc").ToLowerInvariant() == "asc"; - switch ((sortBy ?? "").ToLowerInvariant()) - { - case "ponumber": - q = dir ? q.OrderBy(d => d.PONumber) : q.OrderByDescending(d => d.PONumber); - break; - case "dispatchnumber": - q = dir ? q.OrderBy(d => d.DispatchNumber) : q.OrderByDescending(d => d.DispatchNumber); - break; - case "vendor": - q = dir ? q.OrderBy(d => d.Vendor!.CompanyName) : q.OrderByDescending(d => d.Vendor!.CompanyName); - break; - case "nte": - case "nteamount": - q = dir ? q.OrderBy(d => d.NTEAmount) : q.OrderByDescending(d => d.NTEAmount); - break; - case "status": - q = dir ? q.OrderBy(d => d.Status) : q.OrderByDescending(d => d.Status); - break; - case "scheduleddate": - q = dir ? q.OrderBy(d => d.ScheduledDate) : q.OrderByDescending(d => d.ScheduledDate); - break; - case "completeddate": - q = dir ? q.OrderBy(d => d.CompletedDate) : q.OrderByDescending(d => d.CompletedDate); - break; - default: - q = dir ? q.OrderBy(d => d.DispatchedAt) : q.OrderByDescending(d => d.DispatchedAt); - break; - } - - var rows = await q - .Skip((Math.Max(page, 1) - 1) * pageSize) - .Take(pageSize) - .Select(d => new - { - d.Id, - d.DispatchNumber, - d.PONumber, - d.NTEAmount, - d.Status, - d.DispatchedAt, - d.ScheduledDate, - d.CompletedDate, - d.AcknowledgedAt, - d.VerifiedAt, - VendorId = d.VendorId, - VendorName = d.Vendor != null ? d.Vendor.CompanyName : null, - WorkOrderId = d.WorkOrderId, - PrimaryWOInternalNumber = d.WorkOrder != null ? d.WorkOrder.InternalWONumber : null, - PrimaryWOTitle = d.WorkOrder != null ? d.WorkOrder.WorkerOrderTitle : null, - LocationName = d.WorkOrder != null && d.WorkOrder.Locations != null ? d.WorkOrder.Locations.Name : null, - WorkOrderCount = _db.DispatchWorkOrders.Count(dwo => dwo.DispatchId == d.Id), - PendingUpliftCount = _db.DispatchUpliftRequests.Count(u => u.DispatchId == d.Id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false)) - }) - .ToListAsync(); + var result = await _dispatchService.GetDispatchListAsync( + search, status, vendorId, dateFrom, dateTo, sortBy, sortDir, page, pageSize, cancellationToken); return Ok(new DataResponse { Status = "Success", - Data = new { total, page, pageSize, items = rows } + Data = new { total = result.Total, page = result.Page, pageSize = result.PageSize, items = result.Items } }); } [HttpGet("{id:int}")] - public async Task GetById(int id) + public async Task GetById(int id, CancellationToken cancellationToken) { - var dispatch = await _db.Dispatches - .Include(d => d.Vendor) - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .FirstOrDefaultAsync(d => d.Id == id); + var dispatch = await _dispatchService.GetDispatchDetailAsync(id, cancellationToken); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - var linkedWOs = await _db.DispatchWorkOrders - .Where(dwo => dwo.DispatchId == id) - .Select(dwo => new - { - dwo.WorkOrderId, - InternalWONumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : null, - Title = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : null, - Priority = dwo.WorkOrder != null ? dwo.WorkOrder.Priority : null, - Status = dwo.WorkOrder != null ? dwo.WorkOrder.Status : null, - LocationName = dwo.WorkOrder != null && dwo.WorkOrder.Locations != null ? dwo.WorkOrder.Locations.Name : null, - Trade = dwo.WorkOrder != null ? dwo.WorkOrder.Trade : null, - SubTrade = dwo.WorkOrder != null ? dwo.WorkOrder.SubTrade : null, - Problem = dwo.WorkOrder != null ? dwo.WorkOrder.Problem : null - }).ToListAsync(); - - if (linkedWOs.Count == 0 && dispatch.WorkOrderId.HasValue) - { - var wo = await _db.workOrders - .Include(w => w.Locations) - .FirstOrDefaultAsync(w => w.Id == dispatch.WorkOrderId.Value); - if (wo != null) - { - linkedWOs = new[] { new - { - WorkOrderId = wo.Id, - InternalWONumber = wo.InternalWONumber, - Title = wo.WorkerOrderTitle, - Priority = wo.Priority, - Status = wo.Status, - LocationName = wo.Locations != null ? wo.Locations.Name : null, - Trade = wo.Trade, - SubTrade = wo.SubTrade, - Problem = wo.Problem - }}.ToList(); - } - } - - var checklist = await _db.DispatchChecklistItems - .Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false)) - .OrderBy(c => c.SortOrder) - .Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt, c.SortOrder, c.WorkOrderId }) - .ToListAsync(); - - var signoffs = await _db.DispatchSignoffs - .Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false)) - .Select(s => new { s.Id, s.SignoffType, s.Name, s.Signature, s.SignatureMethod, s.SignedAt }) - .ToListAsync(); - - var rawComments = await (from c in _db.Comments - where c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false) - join u in _db.Users on c.UserId equals u.Id into users - from u in users.DefaultIfEmpty() - orderby c.CreatedDate - select new - { - c.Id, - c.Commenttext, - c.Commenter, - c.CommentType, - c.RecordType, - c.CreatedDate, - c.UserId, - UserFirstName = u != null ? u.FirstName : null, - UserLastName = u != null ? u.LastName : null - }) - .ToListAsync(); - - var comments = rawComments.Select(c => new - { - c.Id, - c.Commenttext, - c.Commenter, - c.CommentType, - c.RecordType, - c.CreatedDate, - UserName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() - }).ToList(); - - var rawUplifts = await (from u in _db.DispatchUpliftRequests - where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false) - join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs - from dec in decs.DefaultIfEmpty() - orderby u.CreatedDate descending - select new - { - u.Id, - u.CurrentNTE, - u.RequestedNTE, - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - RequestedAt = u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null - }) - .ToListAsync(); - - var upliftRequests = rawUplifts.Select(u => new - { - u.Id, - u.CurrentNTE, - u.RequestedNTE, - Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m), - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - u.RequestedAt, - u.DecidedAt, - u.DecisionNote, - DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() - }).ToList(); - - var dispatchNumber = dispatch.DispatchNumber ?? ""; - var workOrderIds = linkedWOs.Select(l => l.WorkOrderId).Where(wid => wid > 0).ToList(); - - var rawAudit = await (from a in _db.WorkOrderAuditLogs - where workOrderIds.Contains(a.WorkOrderId) - && a.FieldName != null - && a.FieldName.Contains(dispatchNumber) - join u in _db.Users on a.UserId equals u.Id into users - from u in users.DefaultIfEmpty() - orderby a.CreatedAt descending - select new - { - a.Id, - a.Action, - a.FieldName, - a.OldValue, - a.NewValue, - a.CreatedAt, - a.UserId, - UserFirstName = u != null ? u.FirstName : null, - UserLastName = u != null ? u.LastName : null - }) - .ToListAsync(); - - var auditLog = rawAudit.Select(a => new - { - Id = a.Id, - Action = a.Action, - FieldName = a.FieldName, - OldValue = a.OldValue, - NewValue = a.NewValue, - CreatedAt = a.CreatedAt, - UserName = string.Join(" ", new[] { a.UserFirstName, a.UserLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() - }).ToList(); - return Ok(new DataResponse { Status = "Success", - Data = new - { - dispatch.Id, - dispatch.DispatchNumber, - dispatch.PONumber, - dispatch.NTEAmount, - dispatch.Description, - dispatch.Status, - dispatch.ScheduledDate, - dispatch.CompletedDate, - dispatch.DispatchedAt, - dispatch.AcknowledgedAt, - dispatch.VerifiedAt, - dispatch.VerifiedBy, - dispatch.EmailSent, - dispatch.WorkOrderId, - Vendor = dispatch.Vendor == null ? null : new - { - dispatch.Vendor.Id, - dispatch.Vendor.CompanyName, - dispatch.Vendor.ContactName, - dispatch.Vendor.Email, - dispatch.Vendor.Phone, - dispatch.Vendor.Address, - dispatch.Vendor.City, - dispatch.Vendor.State, - dispatch.Vendor.Zip, - dispatch.Vendor.TradeSpecialties - }, - PrimaryLocation = dispatch.WorkOrder?.Locations == null ? null : new - { - dispatch.WorkOrder.Locations.Id, - dispatch.WorkOrder.Locations.Name, - dispatch.WorkOrder.Locations.Address1, - dispatch.WorkOrder.Locations.City, - dispatch.WorkOrder.Locations.State, - dispatch.WorkOrder.Locations.Zip - }, - WorkOrders = linkedWOs, - Checklist = checklist, - Signoffs = signoffs, - Comments = comments, - UpliftRequests = upliftRequests, - AuditLog = auditLog - } + Data = dispatch }); } } diff --git a/Api.SeaHavenIndustries/Controllers/DropdownOptionsController.cs b/Api.SeaHavenIndustries/Controllers/DropdownOptionsController.cs index 103ea27..18c0c04 100644 --- a/Api.SeaHavenIndustries/Controllers/DropdownOptionsController.cs +++ b/Api.SeaHavenIndustries/Controllers/DropdownOptionsController.cs @@ -1,7 +1,10 @@ -using Data.SeaHavenIndustries; +using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -10,78 +13,82 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/[controller]")] public class DropdownOptionsController : Controller { - private readonly ApplicationDbContext _db; + private readonly IDropdownOptionsService _dropdownOptionsService; + private readonly ILogger _logger; - public DropdownOptionsController(ApplicationDbContext db) + public DropdownOptionsController(IDropdownOptionsService dropdownOptionsService, ILogger logger) { - _db = db; + _dropdownOptionsService = dropdownOptionsService; + _logger = logger; } [HttpGet] - public async Task GetAll([FromQuery] string? category = null) + public async Task GetAll([FromQuery] string? category = null, CancellationToken cancellationToken = default) { - var query = _db.DropdownOptions.Where(d => d.IsActive); - if (!string.IsNullOrWhiteSpace(category)) - query = query.Where(d => d.Category == category); - - var data = await query.OrderBy(d => d.Category).ThenBy(d => d.SortOrder).ThenBy(d => d.Value).ToListAsync(); + var data = await _dropdownOptionsService.GetAllAsync(category, cancellationToken); return Ok(data); } [HttpGet("ByCategory/{category}")] - public async Task GetByCategory(string category, [FromQuery] string? parent = null) + public async Task GetByCategory(string category, [FromQuery] string? parent = null, CancellationToken cancellationToken = default) { - var query = _db.DropdownOptions.Where(d => d.IsActive && d.Category == category); - if (!string.IsNullOrWhiteSpace(parent)) - query = query.Where(d => d.ParentValue == parent); - - var data = await query.OrderBy(d => d.SortOrder).ThenBy(d => d.Value).ToListAsync(); + var data = await _dropdownOptionsService.GetByCategoryAsync(category, parent, cancellationToken); return Ok(data); } [HttpPost] - public async Task Create([FromBody] DropdownOption model) + public async Task Create([FromBody] DropdownOption_DTO model, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(model.Category) || string.IsNullOrWhiteSpace(model.Value)) - return BadRequest(new { message = "Category and Value are required" }); - - var existing = await _db.DropdownOptions - .AnyAsync(d => d.Category == model.Category && d.Value == model.Value && d.ParentValue == model.ParentValue); - if (existing) - return BadRequest(new { message = "This option already exists" }); - - model.Id = 0; - model.IsActive = true; - _db.DropdownOptions.Add(model); - await _db.SaveChangesAsync(); - return Ok(model); + try + { + var created = await _dropdownOptionsService.CreateAsync(MapToService(model), cancellationToken); + return Ok(created); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = _logger.Sanitize(ex, "The dropdown option could not be saved") }); + } } [HttpPut("{id}")] - public async Task Update(int id, [FromBody] DropdownOption model) + public async Task Update(int id, [FromBody] DropdownOption_DTO model, CancellationToken cancellationToken) { - var item = await _db.DropdownOptions.FindAsync(id); - if (item == null) + try + { + var updated = await _dropdownOptionsService.UpdateAsync(id, MapToService(model), cancellationToken); + return Ok(updated); + } + catch (KeyNotFoundException) + { return NotFound(new { message = "Option not found" }); - - item.Value = model.Value; - item.ParentValue = model.ParentValue; - item.SortOrder = model.SortOrder; - item.IsActive = model.IsActive; - await _db.SaveChangesAsync(); - return Ok(item); + } } [HttpDelete("{id}")] - public async Task Delete(int id) + public async Task Delete(int id, CancellationToken cancellationToken) { - var item = await _db.DropdownOptions.FindAsync(id); - if (item == null) + try + { + await _dropdownOptionsService.DeleteAsync(id, cancellationToken); + return Ok(new { message = "Deleted" }); + } + catch (KeyNotFoundException) + { return NotFound(new { message = "Option not found" }); + } + } - _db.DropdownOptions.Remove(item); - await _db.SaveChangesAsync(); - return Ok(new { message = "Deleted" }); + private static DropdownOptionDTO MapToService(DropdownOption_DTO model) + { + return new DropdownOptionDTO + { + Id = model.Id, + Category = model.Category, + Value = model.Value, + ParentValue = model.ParentValue, + SortOrder = model.SortOrder, + IsActive = model.IsActive + }; } } } diff --git a/Api.SeaHavenIndustries/Controllers/FollowUpController.cs b/Api.SeaHavenIndustries/Controllers/FollowUpController.cs index 02bdf0f..73e2860 100644 --- a/Api.SeaHavenIndustries/Controllers/FollowUpController.cs +++ b/Api.SeaHavenIndustries/Controllers/FollowUpController.cs @@ -1,14 +1,13 @@ using System.Security.Claims; using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SeaHaven.Services.Interfaces; +using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; -using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Interfaces; [ApiController] [Authorize] @@ -17,16 +16,12 @@ using SeaHaven.DataServices.Interfaces; public class FollowUpController : ControllerBase { private readonly IFollowUpService _followUpService; - private readonly IFollowUpDataService _followUpDataService; // Keep for direct entity operations - private readonly ApplicationDbContext _db; // Keep for User lookups - private readonly UserManager _users; + private readonly ILogger _logger; - public FollowUpController(IFollowUpService followUpService, IFollowUpDataService followUpDataService, ApplicationDbContext db, UserManager users) + public FollowUpController(IFollowUpService followUpService, ILogger logger) { _followUpService = followUpService; - _followUpDataService = followUpDataService; - _db = db; - _users = users; + _logger = logger; } [HttpGet("GetFollowUpList")] @@ -34,70 +29,31 @@ public class FollowUpController : ControllerBase string? search = "", string? status = "Open", int page = 1, - int pageSize = 12) + int pageSize = 12, + CancellationToken cancellationToken = default) { - status = string.IsNullOrWhiteSpace(status) ? "Open" : status; - - var pagedResult = await _followUpService.GetFollowUpsPagedAsync(page, pageSize, search, status); + var pagedResult = await _followUpService.GetFollowUpListAsync(page, pageSize, search, status, cancellationToken); var items = pagedResult.Items; - var rows = items.Select(x => new + var data = items.Select(x => new { - Id = x.Id, - Reason = x.Reason, - ScheduleDate = x.ScheduleDate, - ScheduleStartTime = x.ScheduleStartTime, - EmployeeId = x.EmployeeId, - WorkOrderId = x.WorkOrderId, - AccountId = x.AccountId, - LocationId = x.LocationId, - AccountName = x.AccountName ?? "", - AssignedTo = x.EmployeeName ?? "", - LocationName = x.LocationName ?? "", - WorkOrderNumber = "", - InternalWONumber = "", - Status = x.Status, - CreatedDate = x.CreatedDate, - CreatedById = (string?)null - }).ToList(); - - var creatorIds = rows - .Select(r => r.CreatedById) - .Where(id => !string.IsNullOrEmpty(id)) - .Distinct() - .ToList(); - - var creators = await _db.Users - .Where(u => creatorIds.Contains(u.Id)) - .Select(u => new { u.Id, u.FirstName, u.LastName, u.UserName }) - .ToDictionaryAsync(u => u.Id, u => u); - - string NameFor(string? userId) - { - if (string.IsNullOrEmpty(userId) || !creators.TryGetValue(userId, out var u)) return ""; - var full = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim(); - return string.IsNullOrWhiteSpace(full) ? (u.UserName ?? "") : full; - } - - var data = rows.Select(r => new - { - r.Id, - r.Reason, - r.ScheduleDate, - r.ScheduleStartTime, - r.EmployeeId, - r.WorkOrderId, - r.AccountId, - r.LocationId, - r.AccountName, - r.AssignedTo, - r.LocationName, - r.WorkOrderNumber, - r.InternalWONumber, - r.Status, - r.CreatedDate, - r.CreatedById, - CreatedByName = NameFor(r.CreatedById) + x.Id, + x.Reason, + x.ScheduleDate, + x.ScheduleStartTime, + x.EmployeeId, + x.WorkOrderId, + x.AccountId, + x.LocationId, + x.AccountName, + x.AssignedTo, + x.LocationName, + x.WorkOrderNumber, + x.InternalWONumber, + x.Status, + x.CreatedDate, + x.CreatedById, + x.CreatedByName }).ToList(); return Ok(new Pagination_DTO @@ -111,26 +67,12 @@ public class FollowUpController : ControllerBase } [HttpGet("GetById")] - public async Task GetById(int id) + public async Task GetById(int id, CancellationToken cancellationToken) { - var model = await _followUpDataService.GetByIdWithDetailsAsync(id); + var model = await _followUpService.GetFollowUpDetailAsync(id, cancellationToken); if (model == null) return NotFound(); - string createdByName = ""; - if (!string.IsNullOrEmpty(model.createdby)) - { - var u = await _db.Users - .Where(x => x.Id == model.createdby) - .Select(x => new { x.FirstName, x.LastName, x.UserName }) - .FirstOrDefaultAsync(); - if (u != null) - { - var n = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim(); - createdByName = string.IsNullOrWhiteSpace(n) ? (u.UserName ?? "") : n; - } - } - return Ok(new { model.Id, @@ -141,22 +83,20 @@ public class FollowUpController : ControllerBase model.WorkOrderId, model.AccountId, model.LocationId, - AccountName = model.Account?.Name ?? "", - AssignedTo = model.Employee != null - ? (model.Employee.FirstName + " " + model.Employee.LastName) - : "", - LocationName = model.Location?.Name ?? "", - WorkOrderNumber = model.WorkOrder?.WorkerOrderNumber ?? "", - InternalWONumber = model.WorkOrder?.InternalWONumber ?? "", + model.AccountName, + model.AssignedTo, + model.LocationName, + model.WorkOrderNumber, + model.InternalWONumber, model.Status, model.CreatedDate, - CreatedById = model.createdby, - CreatedByName = createdByName + model.CreatedById, + model.CreatedByName }); } [HttpPost("Create")] - public async Task Create(FollowUp_DTO dto) + public async Task Create(FollowUp_DTO dto, CancellationToken cancellationToken) { try { @@ -164,23 +104,9 @@ public class FollowUpController : ControllerBase if (userId == null) return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - var model = new FollowUps - { - Reason = dto.Reason, - ScheduleDate = dto.ScheduleDate, - ScheduleStartTime = dto.ScheduleStartTime, - EmployeeId = dto.EmployeeId, - WorkOrderId = dto.WorkOrderId, - AccountId = dto.AccountId, - LocationId = dto.LocationId, - Status = "Open", - CreatedDate = DateTime.UtcNow, - createdby = userId - }; + var id = await _followUpService.CreateOpenFollowUpAsync(MapToCreateRequest(dto), userId, cancellationToken); - await _followUpDataService.AddAsync(model); - - return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = model.Id }); + return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = id }); } catch (ValidationException vex) { @@ -189,29 +115,16 @@ public class FollowUpController : ControllerBase } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpPost("Update")] - public async Task Update(FollowUp_DTO dto) + public async Task Update(FollowUp_DTO dto, CancellationToken cancellationToken) { try { - var model = await _followUpDataService.GetByIdAsync(dto.Id); - if (model == null) - return NotFound(new Response { Status = "Error", Message = "Follow Up not found" }); - - model.Reason = dto.Reason; - model.ScheduleDate = dto.ScheduleDate; - model.ScheduleStartTime = dto.ScheduleStartTime; - model.EmployeeId = dto.EmployeeId; - model.WorkOrderId = dto.WorkOrderId; - model.AccountId = dto.AccountId; - model.LocationId = dto.LocationId; - model.LastModificationTime = DateTime.UtcNow; - - await _followUpDataService.UpdateAsync(model); + await _followUpService.UpdateFollowUpFieldsAsync(dto.Id, MapToUpdateRequest(dto), cancellationToken); return Ok(new DataResponse { Status = "200", Message = "Follow Up updated" }); } catch (ValidationException vex) @@ -225,47 +138,37 @@ public class FollowUpController : ControllerBase } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpPost("Complete")] - public async Task Complete(int id) + public async Task Complete(int id, CancellationToken cancellationToken) { - var model = await _followUpDataService.GetByIdAsync(id); - if (model == null) return NotFound(); - - model.Status = "Completed"; - model.LastModificationTime = DateTime.UtcNow; - await _followUpDataService.UpdateAsync(model); + var found = await _followUpService.SetFollowUpStatusAsync(id, "Completed", cancellationToken); + if (!found) return NotFound(); return Ok(); } [HttpPost("Cancel")] - public async Task Cancel(int id) + public async Task Cancel(int id, CancellationToken cancellationToken) { - var model = await _followUpDataService.GetByIdAsync(id); - if (model == null) return NotFound(); - - model.Status = "Cancelled"; - model.LastModificationTime = DateTime.UtcNow; - await _followUpDataService.UpdateAsync(model); + var found = await _followUpService.SetFollowUpStatusAsync(id, "Cancelled", cancellationToken); + if (!found) return NotFound(); return Ok(); } [HttpPost("Delete")] - public async Task Delete(int id) + public async Task Delete(int id, CancellationToken cancellationToken) { try { - var model = await _followUpDataService.GetByIdAsync(id); - if (model == null) + var found = await _followUpService.RemoveFollowUpAsync(id, cancellationToken); + if (!found) return NotFound(new Response { Status = "Error", Message = "Follow Up not found" }); - await _followUpDataService.DeleteAsync(id); - return Ok(new DataResponse { Status = "200", Message = "Follow Up deleted" }); } catch (KeyNotFoundException) @@ -274,7 +177,36 @@ public class FollowUpController : ControllerBase } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } + + private static FollowUpCreateRequestDTO MapToCreateRequest(FollowUp_DTO dto) + { + return new FollowUpCreateRequestDTO + { + Reason = dto.Reason, + ScheduleDate = dto.ScheduleDate, + ScheduleStartTime = dto.ScheduleStartTime, + EmployeeId = dto.EmployeeId, + WorkOrderId = dto.WorkOrderId, + AccountId = dto.AccountId, + LocationId = dto.LocationId + }; + } + + private static FollowUpUpdateRequestDTO MapToUpdateRequest(FollowUp_DTO dto) + { + return new FollowUpUpdateRequestDTO + { + Id = dto.Id, + Reason = dto.Reason, + ScheduleDate = dto.ScheduleDate, + ScheduleStartTime = dto.ScheduleStartTime, + EmployeeId = dto.EmployeeId, + WorkOrderId = dto.WorkOrderId, + AccountId = dto.AccountId, + LocationId = dto.LocationId + }; + } } diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 3d671b2..b44f58e 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -1,13 +1,12 @@ using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SeaHaven.Services.Interfaces; +using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; -using SeaHaven.DataServices.Interfaces; -using System.Security.Claims; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -18,12 +17,12 @@ namespace Api.SeaHavenIndustries.Controllers public class LocationController : Controller { private readonly ILocationService _locationService; - private readonly ILocationDataService _locationDataService; // Keep for direct entity operations + private readonly ILogger _logger; - public LocationController(ILocationService locationService, ILocationDataService locationDataService) + public LocationController(ILocationService locationService, ILogger logger) { _locationService = locationService; - _locationDataService = locationDataService; + _logger = logger; } [HttpGet("sites")] @@ -34,9 +33,9 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetLocationList")] - public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { - var pagedResult = await _locationService.GetLocationsPagedAsync(page, pageSize, search); + var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, cancellationToken); var data = pagedResult.Items.Select(l => new { @@ -68,9 +67,9 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("{id}")] - public async Task GetLocationById(int id) + public async Task GetLocationById(int id, CancellationToken cancellationToken) { - var location = await _locationDataService.GetByIdAsync(id); + var location = await _locationService.GetLocationDetailAsync(id, cancellationToken); if (location == null) return NotFound(new Response { Status = "Error", Message = "Location not found" }); @@ -96,25 +95,11 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost] - public async Task AddLocation([FromBody] Location_DTO model) + public async Task AddLocation([FromBody] Location_DTO model, CancellationToken cancellationToken) { try { - var location = new Locations - { - Name = model.Name, - Title = model.Title, - Address1 = model.Address, - City = model.City, - State = model.State, - Zip = model.ZipCode, - PhoneNumber = model.Phone, - Email = model.ContactEmail, - Status = model.Status, - CreatedDate = DateTime.Now - }; - - await _locationDataService.AddAsync(location); + await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), cancellationToken); return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" }); } catch (ValidationException vex) @@ -124,31 +109,16 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpPut("{id}")] - public async Task EditLocation(int id, [FromBody] EditLocation_DTO model) + public async Task EditLocation(int id, [FromBody] EditLocation_DTO model, CancellationToken cancellationToken) { try { - var exist = await _locationDataService.GetByIdAsync(id); - if (exist == null) - return NotFound(new Response { Status = "Error", Message = "Location not found" }); - - exist.Name = model.Name; - exist.Title = model.Title; - exist.Address1 = model.Address; - exist.City = model.City; - exist.State = model.State; - exist.Zip = model.ZipCode; - exist.PhoneNumber = model.Phone; - exist.Email = model.ContactEmail; - exist.Status = model.Status; - exist.LastModificationTime = DateTime.Now; - - await _locationDataService.UpdateAsync(exist); + await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), cancellationToken); return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" }); } catch (ValidationException vex) @@ -162,20 +132,19 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpDelete("{id}")] - public async Task DeleteLocation(int id) + public async Task DeleteLocation(int id, CancellationToken cancellationToken) { try { - var exist = await _locationDataService.GetByIdAsync(id); - if (exist == null) + var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken); + if (!found) return NotFound(new Response { Status = "Error", Message = "Location not found" }); - await _locationDataService.DeleteAsync(id); return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" }); } catch (KeyNotFoundException) @@ -184,14 +153,46 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet("DeleteLocation")] - public async Task DeleteLocationByQuery(int id) + public async Task DeleteLocationByQuery(int id, CancellationToken cancellationToken) { - return await DeleteLocation(id); + return await DeleteLocation(id, cancellationToken); + } + + private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model) + { + return new LocationCreateRequestDTO + { + Name = model.Name, + Title = model.Title, + Address = model.Address, + City = model.City, + State = model.State, + ZipCode = model.ZipCode, + Phone = model.Phone, + ContactEmail = model.ContactEmail, + Status = model.Status + }; + } + + private static LocationUpdateRequestDTO MapToUpdateRequest(EditLocation_DTO model) + { + return new LocationUpdateRequestDTO + { + Name = model.Name, + Title = model.Title, + Address = model.Address, + City = model.City, + State = model.State, + ZipCode = model.ZipCode, + Phone = model.Phone, + ContactEmail = model.ContactEmail, + Status = model.Status + }; } } } diff --git a/Api.SeaHavenIndustries/Controllers/QuotesController.cs b/Api.SeaHavenIndustries/Controllers/QuotesController.cs index 311fb33..97e111b 100644 --- a/Api.SeaHavenIndustries/Controllers/QuotesController.cs +++ b/Api.SeaHavenIndustries/Controllers/QuotesController.cs @@ -1,12 +1,11 @@ using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.Data.SqlClient; -using Microsoft.EntityFrameworkCore; -using System.Linq; -using static System.Runtime.InteropServices.JavaScript.JSType; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -15,178 +14,82 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/Quotes")] public class QuotesController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - private readonly IWebHostEnvironment _webHostEnvironment; - IHttpContextAccessor _httpContext; - public QuotesController(UserManager userManager, ApplicationDbContext db, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext) + private readonly IQuotesService _quotesService; + private readonly ILogger _logger; + + public QuotesController(IQuotesService quotesService, ILogger logger) { - _userManager = userManager; - _db = db; - _webHostEnvironment = webHostEnvironment; - _httpContext = httpContext; + _quotesService = quotesService; + _logger = logger; } + [HttpPost] [Route("AddQuote")] - public async Task AddQuote([FromBody] Quote_DTO model) + public async Task AddQuote([FromBody] Quote_DTO model, CancellationToken cancellationToken) { try { - Random rnd = new Random(); - int id = rnd.Next(1, 99999); - Quotes quotes = new Quotes(); - quotes.QuotId = id.ToString(); - quotes.ContactId = model.ContactId; - quotes.PO = model.PO; - quotes.TT = model.TT; - quotes.ProjectName = model.ProjectName; - quotes.Date = DateTime.Parse(model.Date); - quotes.WorkorderId = model.WorkorderId; - quotes.SiteName = model.SiteName; - quotes.SiteId = model.SiteId; - quotes.BillingAddress = model.BillingAddress; - quotes.LineItems = quotes.LineItems == null ? new List() : quotes.LineItems; - if (model.LineItems != null && model.LineItems.Any()) - { - foreach (var lineItemDto in model.LineItems) - { - QuotesLineItems lineItem = new QuotesLineItems() - { - LineitemNumber = lineItemDto.LineitemNumber, - Lineitem = lineItemDto.Lineitem, - Descriptions = lineItemDto.Descriptions - }; - quotes.LineItems.Add(lineItem); - } - } - _db.Quotes.Add(quotes); - await _db.SaveChangesAsync(); + await _quotesService.CreateQuoteAsync(MapToCreateInput(model), cancellationToken); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpPut] [Route("EditQuote")] - public async Task EditQuote([FromBody] EditQuote_DTO model) + public async Task EditQuote([FromBody] EditQuote_DTO model, CancellationToken cancellationToken) { try { - var quotes = _db.Quotes.Where(w => w.Id == model.id).FirstOrDefault(); - if (quotes != null) - { - quotes.QuotId = model.QuotId; - quotes.ContactId = model.ContactId; - quotes.PO = model.PO; - quotes.TT = model.TT; - quotes.ProjectName = model.ProjectName; - quotes.Date = DateTime.Parse(model.Date); - quotes.WorkorderId = model.WorkorderId; - quotes.SiteName = model.SiteName; - quotes.SiteId = model.SiteId; - quotes.BillingAddress = model.BillingAddress; - - var existQuoteLine = _db.QuotesLines.Where(w => w.QuoteId == model.id).ToList(); - if (existQuoteLine.Count() > 0) - { - _db.QuotesLines.RemoveRange(existQuoteLine); - } - quotes.LineItems = quotes.LineItems == null ? new List() : quotes.LineItems; - if (model.LineItems != null && model.LineItems.Any()) - { - foreach (var lineItemDto in model.LineItems) - { - QuotesLineItems lineItem = new QuotesLineItems() - { - LineitemNumber = lineItemDto.LineitemNumber, - Lineitem = lineItemDto.Lineitem, - Descriptions = lineItemDto.Descriptions - }; - quotes.LineItems.Add(lineItem); - } - } - _db.Update(quotes); - } - else - { + var updated = await _quotesService.UpdateQuoteAsync(MapToUpdateInput(model), cancellationToken); + if (!updated) return BadRequest("ID not matched!"); - } - await _db.SaveChangesAsync(); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpDelete] [Route("DeleteQuote")] - public IActionResult DeleteQuote(EditQuote_DTO model) + public async Task DeleteQuote(EditQuote_DTO model, CancellationToken cancellationToken) { int id = model.id; - try + try { - var data = _db.Quotes.Where(w => w.Id == id).FirstOrDefault(); - if (data != null) - { - var existlineitem = _db.QuotesLines.Where(w => w.QuoteId == id).ToList(); - if (existlineitem.Any()) - { - _db.QuotesLines.RemoveRange(existlineitem); - } - _db.Quotes.Remove(data); - } - else - { + var deleted = await _quotesService.DeleteQuoteAsync(id, cancellationToken); + if (!deleted) return BadRequest("ID not matched!"); - } - _db.SaveChanges(); + return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet] [Route("GetQuoutes")] - public IActionResult GetQuotes(string search = "", int page = 1, int pageSize = 10) + public async Task GetQuotes(string search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { - var totalCount = _db.Quotes.Count(); - var data = _db.Quotes.Include(s => s.WorkOrder).AsNoTracking().Where(w => (w.QuotId ?? "").ToLower().Contains(search.ToLower()) || (w.SiteName ?? "").ToLower().Contains(search.ToLower()) || (w.WorkOrder != null && (w.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(search.ToLower()))).Select(s => new - { - s.Id, - QuoteId = "QO-" + s.QuotId, - WorkOrder = s.WorkOrder != null ? s.WorkOrder.WorkerOrderNumber : "", - s.ProjectName, - TotalAmount = "$" + (s.LineItems != null ? s.LineItems.Sum(l => l.Lineitem ?? 0) : 0).ToString(), - s.SiteName, - s.SiteId, - s.PO, - s.TT, - s.BillingAddress, - s.Status, - s.ContactId, - s.Date, - lineitems = s.LineItems.Select(q => new { q.Descriptions, q.Lineitem, q.LineitemNumber}), - }).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); + var result = await _quotesService.GetQuotesPagedAsync(page, pageSize, search, cancellationToken); - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var totalPages = (int)Math.Ceiling(result.TotalCount / (double)pageSize); - // Create a view model containing the paged items and pagination metadata var viewModel = new Pagination_DTO { - Data = data, + Data = result.Items, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = result.TotalCount, TotalPages = totalPages }; return Ok(viewModel); @@ -194,66 +97,85 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetQuoutesdetailsAgainstWorkOrder")] - public async Task GetQuoutesdetailsAgainstWorkOrder(int woId) + public async Task GetQuoutesdetailsAgainstWorkOrder(int woId, CancellationToken cancellationToken) { - var data = await _db.workOrders.Include(s => s.Locations).Where(w => w.Id == woId).Select(s => new - { - s.Id, - s.PO, - s.TT, - Title = s.Locations != null ? s.Locations.Title : "", - Name = s.Locations != null ? s.Locations.Name : "" - }).ToListAsync(); + var data = await _quotesService.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken); return Ok(data); } [HttpGet] [Route("GetQuoutesById")] - public async Task GetQuoutesById(int id) + public async Task GetQuoutesById(int id, CancellationToken cancellationToken) { - var data = await _db.Quotes.Where(s => s.Id == id).Include(s => s.WorkOrder).Select(s => new - { - s.Id, - s.QuotId, - s.Date, - Name = s.Contacts != null ? (s.Contacts.FirstName + " " + s.Contacts.LastName).Trim() : "", - s.PO, - s.TT, - s.SiteName, - s.ProjectName, - s.SiteId, - s.BillingAddress, - s.ShippingAddress, - lineitems = s.LineItems.Select(q => new { q.Descriptions, q.Lineitem }), - TotalAmount = "$" + (s.LineItems != null ? s.LineItems.Sum(l => l.Lineitem ?? 0) : 0).ToString() - }).FirstOrDefaultAsync(); + var data = await _quotesService.GetQuoteDetailsByIdAsync(id, cancellationToken); return Ok(data); } [HttpGet] [Route("GetReporting")] - public async Task GetReporting() + public async Task GetReporting(CancellationToken cancellationToken) { - var totalCreatedWorkOrder = await _db.workOrders.CountAsync(); - var totalCompleted = _db.workOrders.Count(w => w.Status == "Done"); - var totalSev1 = _db.workOrders.Count(w => w.Priority == "Sev 1"); - var totalSev2 = _db.workOrders.Count(w => w.Priority == "Sev 2"); - var totalSev3 = _db.workOrders.Count(w => w.Priority == "Sev 3"); - var totalSev4 = _db.workOrders.Count(w => w.Priority == "Sev 4"); - var totalSev5 = _db.workOrders.Count(w => w.Priority == "Sev 5"); + var data = await _quotesService.GetReportingAsync(cancellationToken); - var data = new Reporting_DTO + var reportingDto = new Reporting_DTO { - TotalCreatedWorkOrder = totalCreatedWorkOrder, - TotalCompleted = totalCompleted, - TotalSev1 = totalSev1, - TotalSev2 = totalSev2, - TotalSev3 = totalSev3, - TotalSev4 = totalSev4, - TotalSev5 = totalSev5 + TotalCreatedWorkOrder = data.TotalCreatedWorkOrder, + TotalCompleted = data.TotalCompleted, + TotalSev1 = data.TotalSev1, + TotalSev2 = data.TotalSev2, + TotalSev3 = data.TotalSev3, + TotalSev4 = data.TotalSev4, + TotalSev5 = data.TotalSev5 }; - return Ok(data); + return Ok(reportingDto); + } + + private static CreateQuoteInput MapToCreateInput(Quote_DTO dto) + { + return new CreateQuoteInput + { + QuotId = dto.QuotId, + ContactId = dto.ContactId, + PO = dto.PO, + TT = dto.TT, + ProjectName = dto.ProjectName, + Date = dto.Date != null ? DateTime.Parse(dto.Date) : null, + WorkorderId = dto.WorkorderId, + SiteName = dto.SiteName, + SiteId = dto.SiteId, + BillingAddress = dto.BillingAddress, + LineItems = dto.LineItems?.Select(li => new QuoteLineItemInput + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }).ToList() + }; + } + + private static UpdateQuoteInput MapToUpdateInput(EditQuote_DTO dto) + { + return new UpdateQuoteInput + { + Id = dto.id, + QuotId = dto.QuotId, + ContactId = dto.ContactId, + PO = dto.PO, + TT = dto.TT, + ProjectName = dto.ProjectName, + Date = dto.Date != null ? DateTime.Parse(dto.Date) : null, + WorkorderId = dto.WorkorderId, + SiteName = dto.SiteName, + SiteId = dto.SiteId, + BillingAddress = dto.BillingAddress, + LineItems = dto.LineItems?.Select(li => new QuoteLineItemInput + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }).ToList() + }; } } } diff --git a/Api.SeaHavenIndustries/Controllers/SettingsController.cs b/Api.SeaHavenIndustries/Controllers/SettingsController.cs index 5a0510a..c1be8fc 100644 --- a/Api.SeaHavenIndustries/Controllers/SettingsController.cs +++ b/Api.SeaHavenIndustries/Controllers/SettingsController.cs @@ -1,11 +1,11 @@ using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.Helper.ViewModel; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using static System.Runtime.InteropServices.JavaScript.JSType; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -14,107 +14,78 @@ namespace Api.SeaHavenIndustries.Controllers [Route("[controller]")] public class SettingsController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - public SettingsController(UserManager userManager, ApplicationDbContext db) + private readonly ISettingsService _settingsService; + private readonly ILogger _logger; + + public SettingsController(ISettingsService settingsService, ILogger logger) { - _userManager = userManager; - _db = db; + _settingsService = settingsService; + _logger = logger; } [HttpPost] [Route("AddCategory")] - public async Task AddCategory(Category_DTO model) + public async Task AddCategory(Category_DTO model, CancellationToken cancellationToken) { try { - Category category = new Category(); - category.Name = model.Name; - _db.Categories.Add(category); - await _db.SaveChangesAsync(); + await _settingsService.AddCategoryAsync(model.Name, cancellationToken); return Ok(new DataResponse { Message = "Successfully Saved Data", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpPost] [Route("EditCategory")] - public async Task EditCategory(EditCategory_DTO model) + public async Task EditCategory(EditCategory_DTO model, CancellationToken cancellationToken) { try { - var exist = _db.Categories.Where(w => w.Id == model.Id).FirstOrDefault(); - if (exist == null) + var found = await _settingsService.UpdateCategoryAsync(model.Id, model.Name, cancellationToken); + if (!found) return BadRequest(new Response { Status = "Error", Message = "Category not found" }); - exist.Name = model.Name; - _db.Categories.Update(exist); - await _db.SaveChangesAsync(); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet] [Route("DeleteCategory")] - public async Task DeleteCategory(int id) + public async Task DeleteCategory(int id, CancellationToken cancellationToken) { try { - var exist = _db.Categories.Where(w => w.Id == id).FirstOrDefault(); - if (exist != null) - { - var chkexist = _db.Templates.Where(w => w.CategoryId == id).ToList(); - if (chkexist != null) - { - _db.RemoveRange(chkexist); - } - var chkworkordercategory = _db.workOrderCategories.Where(w => w.CategoryId == id).ToList(); - if (chkworkordercategory != null) - { - _db.RemoveRange(chkworkordercategory); - } - _db.Remove(exist); - } - else - { + var found = await _settingsService.DeleteCategoryAsync(id, cancellationToken); + if (!found) return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); - } - await _db.SaveChangesAsync(); return Ok(new DataResponse { Message = "Deleted Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } - [HttpGet] [Route("GetCategoryList")] - public IActionResult GetCategoryList(int page = 1, int pageSize = 10) + public async Task GetCategoryList(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { - var totalCount = _db.Categories.Count(); - var data = _db.Categories.Select(s => new - { - s.Id, - s.Name - }).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); + var (items, total) = await _settingsService.GetCategoryListAsync(page, pageSize, cancellationToken); - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var totalPages = (int)Math.Ceiling(total / (double)pageSize); var viewModel = new Pagination_DTO { - Data = data, + Data = items, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = total, TotalPages = totalPages }; return Ok(viewModel); @@ -122,117 +93,79 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetTemplates")] - public IActionResult GetTemplates(int page = 1, int pageSize = 10) + public async Task GetTemplates(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { try { - var totalCount = _db.workOrders.Where(w=> w.istemplate == true).Count(); - var data = _db.workOrders.Where(w => w.istemplate == true).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); + var (items, total) = await _settingsService.GetTemplatesAsync(page, pageSize, cancellationToken); - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var totalPages = (int)Math.Ceiling(total / (double)pageSize); - // Create a view model containing the paged items and pagination metadata var viewModel = new Pagination_DTO { - Data = data, + Data = items, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = total, TotalPages = totalPages }; return Ok(viewModel); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet] [Route("GetUsers")] - public async Task GetUsers(string? search = "", int page = 1, int pageSize = 10) + public async Task GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { try { - var totalCount = _db.Users.Count(); - var allUsers = _db.Users.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower())).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); + var (users, total) = await _settingsService.GetUsersAsync(search, page, pageSize, cancellationToken); - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - var filteredUsers = new List(); - - foreach (var user in allUsers) + var filteredUsers = users.Select(u => new UserRoleViewModel { - var roles = await _userManager.GetRolesAsync(user); - if (!roles.Contains("Admin")) - { - filteredUsers.Add(new UserRoleViewModel - { - RoleName = roles.Count() > 0 ? roles.First() : "", - Email = user.Email, - UserName = user.FirstName, - Date = user.CreatedDate?.Date.ToString(), - Status = user.UniqueName, - Id = user.Id - }); - } - } - // Create a view model containing the paged items and pagination metadata + RoleName = u.RoleName, + Email = u.Email, + UserName = u.UserName, + Date = u.Date, + Status = u.Status, + Id = u.Id + }).ToList(); + + var totalPages = (int)Math.Ceiling(total / (double)pageSize); + var viewModel = new Pagination_DTO { Data = filteredUsers, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = total, TotalPages = totalPages }; return Ok(viewModel); } catch (Exception ex) { - return BadRequest(new { Status = "Error", Message = ex.Message }); + return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet] [Route("GetRoles")] - public async Task GetRoles() + public async Task GetRoles(CancellationToken cancellationToken) { try { - var data = _db.Roles.ToList(); - List roles = new List(); - foreach (var role in data) - { - if (role.Name == "Admin") - { - roles.Add(new Roles_DTO { Name = role.Name, Description = "Full Access" }); - } - else if(role.Name == "Supervisor") - { - roles.Add(new Roles_DTO { Name = role.Name, Description = "Access to Team's Work Orders Only" }); - - } - else if(role.Name == "Manager") - { - roles.Add(new Roles_DTO { Name = role.Name, Description = "Full access accept quotes" }); - - } - else if(role.Name =="User") - { - roles.Add(new Roles_DTO { Name = role.Name, Description = "Access to Assigned Work Orders Only" }); - - } - } - return Ok(roles); + var roles = await _settingsService.GetRolesAsync(cancellationToken); + var result = roles.Select(r => new Roles_DTO { Name = r.Name, Description = r.Description }).ToList(); + return Ok(result); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } } diff --git a/Api.SeaHavenIndustries/Controllers/SyncController.cs b/Api.SeaHavenIndustries/Controllers/SyncController.cs index d246273..d392bff 100644 --- a/Api.SeaHavenIndustries/Controllers/SyncController.cs +++ b/Api.SeaHavenIndustries/Controllers/SyncController.cs @@ -1,11 +1,8 @@ -using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.Model; using Api.SeaHavenIndustries.Options; -using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -14,18 +11,13 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/[controller]")] public class SyncController : Controller { - private readonly ApplicationDbContext _db; - private readonly AmazonDynamoDBClient _dynamo; + private readonly ISyncService _syncService; private readonly SyncOptions _syncOptions; - private const string WO_TABLE = "WorkOrders"; - private const string COMMENTS_TABLE = "WorkOrderComments"; - private const string VENDOR_REPLIES_TABLE = "VendorReplies"; - public SyncController(ApplicationDbContext db, IOptions syncOptions) + public SyncController(ISyncService syncService, IOptions syncOptions) { - _db = db; + _syncService = syncService; _syncOptions = syncOptions.Value; - _dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1); } private IActionResult? SyncDisabledResult() @@ -40,419 +32,73 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("WorkOrders")] - public async Task SyncWorkOrders() + public async Task SyncWorkOrders(CancellationToken cancellationToken) { if (SyncDisabledResult() is { } disabled) return disabled; - var synced = 0; - var created = 0; - var updated = 0; - var locationsCreated = 0; - var lastInternalWO = await _db.workOrders - .Where(w => w.InternalWONumber != null && w.InternalWONumber != "") - .OrderByDescending(w => w.InternalWONumber) - .Select(w => w.InternalWONumber) - .FirstOrDefaultAsync(); - int nextInternal = 10000001; - if (lastInternalWO != null && int.TryParse(lastInternalWO, out var parsed)) - nextInternal = parsed + 1; - - Dictionary? lastKey = null; - - do - { - var request = new ScanRequest - { - TableName = WO_TABLE, - Limit = 100, - ExclusiveStartKey = lastKey - }; - - var response = await _dynamo.ScanAsync(request); - - foreach (var item in response.Items) - { - var externalId = GetString(item, "work_order_id"); - if (string.IsNullOrEmpty(externalId)) continue; - - var existing = await _db.workOrders - .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId); - - var (locationId, locationWasCreated) = await ResolveLocationId( - GetString(item, "site_code"), - GetString(item, "building"), - GetString(item, "address")); - - if (locationWasCreated) - locationsCreated++; - - if (existing == null) - { - var wo = new WorkOrder - { - InternalWONumber = (nextInternal++).ToString("D8"), - ExternalWorkOrderId = externalId, - WorkerOrderNumber = externalId, - WorkerOrderTitle = GetString(item, "description"), - Description = GetString(item, "description"), - Status = MapStatus(GetString(item, "wo_status")), - Priority = MapSeverityToPriority(GetString(item, "severity")), - Severity = GetString(item, "severity"), - Customer = GetString(item, "customer"), - SiteCode = GetString(item, "site_code"), - Building = GetString(item, "building"), - LocationId = locationId, - DueDate = ParseDate(GetString(item, "due_date")), - DateReported = ParseDate(GetString(item, "date_reported")), - ScheduledStart = ParseDate(GetString(item, "scheduled_start")), - AssignTo = null, - SourceEmailS3Key = GetString(item, "source_email_s3_key"), - CreatedDate = ParseDate(GetString(item, "created_at")) ?? DateTime.UtcNow, - istemplate = false - }; - _db.workOrders.Add(wo); - created++; - } - else - { - existing.WorkerOrderTitle = GetString(item, "description") ?? existing.WorkerOrderTitle; - existing.Description = GetString(item, "description") ?? existing.Description; - existing.Status = MapStatus(GetString(item, "wo_status")) ?? existing.Status; - existing.Priority = MapSeverityToPriority(GetString(item, "severity")) ?? existing.Priority; - existing.Severity = GetString(item, "severity") ?? existing.Severity; - existing.SiteCode = GetString(item, "site_code") ?? existing.SiteCode; - existing.Building = GetString(item, "building") ?? existing.Building; - existing.LocationId = locationId ?? existing.LocationId; - existing.DueDate = ParseDate(GetString(item, "due_date")) ?? existing.DueDate; - existing.DateReported = ParseDate(GetString(item, "date_reported")) ?? existing.DateReported; - existing.ScheduledStart = ParseDate(GetString(item, "scheduled_start")) ?? existing.ScheduledStart; - existing.SourceEmailS3Key = GetString(item, "source_email_s3_key") ?? existing.SourceEmailS3Key; - updated++; - } - - synced++; - - if (synced % 100 == 0) - await _db.SaveChangesAsync(); - } - - lastKey = response.LastEvaluatedKey; - - } while (lastKey != null && lastKey.Count > 0); - - await _db.SaveChangesAsync(); - - return Ok(new - { - synced, - created, - updated, - locationsCreated = await _db.Locations.CountAsync() - }); + var result = await _syncService.SyncWorkOrdersAsync(cancellationToken); + return Ok(result); } [HttpPost("Comments")] - public async Task SyncComments() + public async Task SyncComments(CancellationToken cancellationToken) { if (SyncDisabledResult() is { } disabled) return disabled; - var synced = 0; - var created = 0; - var skipped = 0; - Dictionary? lastKey = null; - - do - { - var request = new ScanRequest - { - TableName = COMMENTS_TABLE, - Limit = 100, - ExclusiveStartKey = lastKey - }; - - var response = await _dynamo.ScanAsync(request); - - foreach (var item in response.Items) - { - var externalCommentId = GetString(item, "comment_id"); - var externalWoId = GetString(item, "work_order_id"); - - if (string.IsNullOrEmpty(externalCommentId) || string.IsNullOrEmpty(externalWoId)) - continue; - - var alreadyExists = await _db.Comments - .AnyAsync(c => c.ExternalCommentId == externalCommentId); - - if (alreadyExists) - { - skipped++; - synced++; - continue; - } - - var workOrder = await _db.workOrders - .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWoId); - - if (workOrder == null) - { - skipped++; - continue; - } - - var comment = new Comments - { - ExternalCommentId = externalCommentId, - WorkerOrderId = workOrder.Id, - Commenttext = GetString(item, "text"), - Commenter = GetString(item, "commenter"), - RecordType = GetString(item, "record_type"), - CommentType = "customer", - CreatedDate = ParseDate(GetString(item, "created_at")) ?? DateTime.UtcNow, - }; - - _db.Comments.Add(comment); - created++; - synced++; - - if (synced % 100 == 0) - await _db.SaveChangesAsync(); - } - - lastKey = response.LastEvaluatedKey; - - } while (lastKey != null && lastKey.Count > 0); - - await _db.SaveChangesAsync(); - - return Ok(new { synced, created, skipped }); + var result = await _syncService.SyncCommentsAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillInternalWONumbers")] - public async Task BackfillInternalWONumbers() + public async Task BackfillInternalWONumbers(CancellationToken cancellationToken) { if (SyncDisabledResult() is { } disabled) return disabled; - var wosMissing = await _db.workOrders - .Where(w => w.InternalWONumber == null || w.InternalWONumber == "") - .OrderBy(w => w.Id) - .ToListAsync(); - - if (wosMissing.Count == 0) - return Ok(new { updated = 0 }); - - var lastWO = await _db.workOrders - .Where(w => w.InternalWONumber != null && w.InternalWONumber != "") - .OrderByDescending(w => w.InternalWONumber) - .Select(w => w.InternalWONumber) - .FirstOrDefaultAsync(); - - int next = 10000001; - if (lastWO != null && int.TryParse(lastWO, out var lastNum)) - next = lastNum + 1; - - foreach (var wo in wosMissing) - { - wo.InternalWONumber = next.ToString("D8"); - next++; - } - - await _db.SaveChangesAsync(); - return Ok(new { updated = wosMissing.Count }); + var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillDispatchNumbers")] - public async Task BackfillDispatchNumbers() + public async Task BackfillDispatchNumbers(CancellationToken cancellationToken) { if (SyncDisabledResult() is { } disabled) return disabled; - var missing = await _db.Dispatches - .Where(d => d.DispatchNumber == null || d.DispatchNumber == "") - .OrderBy(d => d.Id) - .ToListAsync(); - if (missing.Count == 0) return Ok(new { updated = 0 }); - - int next = 1; - var last = await _db.Dispatches - .Where(d => d.DispatchNumber != null && d.DispatchNumber != "") - .OrderByDescending(d => d.DispatchNumber) - .Select(d => d.DispatchNumber) - .FirstOrDefaultAsync(); - if (last != null && last.StartsWith("DSP-") && int.TryParse(last.Substring(4), out var n)) - next = n + 1; - - foreach (var d in missing) - { - d.DispatchNumber = $"DSP-{next:D5}"; - next++; - } - await _db.SaveChangesAsync(); - return Ok(new { updated = missing.Count }); + var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken); + return Ok(result); } [HttpPost("VendorReplies")] - public async Task SyncVendorReplies() + public async Task SyncVendorReplies(CancellationToken cancellationToken) { if (SyncDisabledResult() is { } disabled) return disabled; - var synced = 0; - var created = 0; - var skipped = 0; - Dictionary? lastKey = null; - - do - { - var request = new ScanRequest - { - TableName = VENDOR_REPLIES_TABLE, - Limit = 100, - ExclusiveStartKey = lastKey - }; - - var response = await _dynamo.ScanAsync(request); - - foreach (var item in response.Items) - { - var replyId = GetString(item, "reply_id"); - var woNumber = GetString(item, "internal_wo_number"); - var dispatchNumber = GetString(item, "dispatch_number"); - - if (string.IsNullOrEmpty(replyId) || string.IsNullOrEmpty(woNumber)) - continue; - - var alreadyExists = await _db.Comments - .AnyAsync(c => c.ExternalCommentId == replyId); - if (alreadyExists) { skipped++; synced++; continue; } - - var workOrder = await _db.workOrders - .FirstOrDefaultAsync(w => w.InternalWONumber == woNumber); - if (workOrder == null) { skipped++; continue; } - - int? dispatchId = null; - if (!string.IsNullOrWhiteSpace(dispatchNumber)) - { - var dispatch = await _db.Dispatches - .FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrder.Id); - dispatchId = dispatch?.Id; - } - - var comment = new Comments - { - ExternalCommentId = replyId, - WorkerOrderId = workOrder.Id, - DispatchId = dispatchId, - Commenttext = GetString(item, "reply_text"), - Commenter = GetString(item, "sender_email"), - CommentType = "vendor", - RecordType = "vendor_reply", - CreatedDate = ParseDate(GetString(item, "received_at")) ?? DateTime.UtcNow, - }; - - _db.Comments.Add(comment); - created++; - synced++; - - if (synced % 50 == 0) - await _db.SaveChangesAsync(); - } - - lastKey = response.LastEvaluatedKey; - } while (lastKey != null && lastKey.Count > 0); - - await _db.SaveChangesAsync(); - return Ok(new { synced, created, skipped }); + var result = await _syncService.SyncVendorRepliesAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillCommentTypes")] - public async Task BackfillCommentTypes() + public async Task BackfillCommentTypes(CancellationToken cancellationToken) { if (SyncDisabledResult() is { } disabled) return disabled; - var updated = await _db.Database.ExecuteSqlRawAsync( - "UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')"); - return Ok(new { updated }); + var result = await _syncService.BackfillCommentTypesAsync(cancellationToken); + return Ok(result); } [HttpPost("All")] - public async Task SyncAll() + public async Task SyncAll(CancellationToken cancellationToken) { if (SyncDisabledResult() is { } disabled) return disabled; - var woResult = await SyncWorkOrders() as OkObjectResult; - var commentResult = await SyncComments() as OkObjectResult; - - return Ok(new - { - workOrders = woResult?.Value, - comments = commentResult?.Value - }); - } - - private async Task<(int? LocationId, bool Created)> ResolveLocationId(string? siteCode, string? building, string? address) - { - if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building)) - return (null, false); - - var matchCode = siteCode ?? building; - var existing = await _db.Locations - .FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode); - - if (existing != null) - return (existing.Id, false); - - var location = new Locations - { - Name = matchCode, - Title = building, - Address1 = address, - Status = "Active" - }; - - _db.Locations.Add(location); - await _db.SaveChangesAsync(); - - return (location.Id, true); - } - - private static string? GetString(Dictionary item, string key) - { - if (item.TryGetValue(key, out var val) && val.S != null) - return val.S; - return null; - } - - private static DateTime? ParseDate(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - if (DateTime.TryParse(value, out var dt)) return dt; - return null; - } - - private static string? MapStatus(string? dynamoStatus) - { - return dynamoStatus switch - { - "new" => "Open", - "assigned" => "Open", - "in_progress" => "In Progress", - "on_hold" => "On Hold", - "completed" => "Done", - "cancelled" => "Cancelled", - "unknown" => "Open", - _ => "Open" - }; - } - - private static string? MapSeverityToPriority(string? severity) - { - if (string.IsNullOrWhiteSpace(severity)) return null; - return $"Sev {severity}"; + var result = await _syncService.SyncAllAsync(cancellationToken); + return Ok(result); } } } diff --git a/Api.SeaHavenIndustries/Controllers/TaskListTemplateController.cs b/Api.SeaHavenIndustries/Controllers/TaskListTemplateController.cs index d424c9a..b3bd441 100644 --- a/Api.SeaHavenIndustries/Controllers/TaskListTemplateController.cs +++ b/Api.SeaHavenIndustries/Controllers/TaskListTemplateController.cs @@ -1,8 +1,11 @@ using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -12,57 +15,26 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/tasklisttemplates")] public class TaskListTemplateController : Controller { - private readonly ApplicationDbContext _db; + private readonly ITaskListTemplateService _taskListTemplateService; + private readonly ILogger _logger; - public TaskListTemplateController(ApplicationDbContext db) + public TaskListTemplateController(ITaskListTemplateService taskListTemplateService, ILogger logger) { - _db = db; + _taskListTemplateService = taskListTemplateService; + _logger = logger; } [HttpGet] - public async Task GetAll() + public async Task GetAll(CancellationToken cancellationToken) { - var data = await _db.TaskListTemplates - .Where(t => t.IsActive) - .Include(t => t.Items.OrderBy(i => i.SortOrder)) - .OrderBy(t => t.Name) - .Select(t => new - { - t.Id, - t.Name, - t.Description, - t.IsActive, - items = t.Items.OrderBy(i => i.SortOrder).Select(i => new - { - i.Id, - i.ItemText, - i.SortOrder - }).ToList() - }) - .ToListAsync(); + var data = await _taskListTemplateService.GetAllAsync(cancellationToken); return Ok(data); } [HttpGet("{id}")] - public async Task GetById(int id) + public async Task GetById(int id, CancellationToken cancellationToken) { - var template = await _db.TaskListTemplates - .Include(t => t.Items.OrderBy(i => i.SortOrder)) - .Where(t => t.Id == id) - .Select(t => new - { - t.Id, - t.Name, - t.Description, - t.IsActive, - items = t.Items.OrderBy(i => i.SortOrder).Select(i => new - { - i.Id, - i.ItemText, - i.SortOrder - }).ToList() - }) - .FirstOrDefaultAsync(); + var template = await _taskListTemplateService.GetByIdAsync(id, cancellationToken); if (template == null) return NotFound(new Response { Status = "Error", Message = "Template not found" }); @@ -71,76 +43,61 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost] - public async Task Create([FromBody] TaskListTemplate_DTO model) + public async Task Create([FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken) { try { - var template = new TaskListTemplate - { - Name = model.Name, - Description = model.Description, - IsActive = model.IsActive, - Items = model.Items.Select((item, idx) => new TaskListTemplateItem - { - ItemText = item.ItemText, - SortOrder = item.SortOrder > 0 ? item.SortOrder : idx - }).ToList() - }; - - _db.TaskListTemplates.Add(template); - await _db.SaveChangesAsync(); - return Ok(new { message = "Template created", id = template.Id }); + var input = MapToInput(model); + var id = await _taskListTemplateService.CreateAsync(input, cancellationToken); + return Ok(new { message = "Template created", id }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpPut("{id}")] - public async Task Update(int id, [FromBody] TaskListTemplate_DTO model) + public async Task Update(int id, [FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken) { try { - var template = await _db.TaskListTemplates - .Include(t => t.Items) - .FirstOrDefaultAsync(t => t.Id == id); - - if (template == null) + var input = MapToInput(model); + var updated = await _taskListTemplateService.UpdateAsync(id, input, cancellationToken); + if (!updated) return NotFound(new Response { Status = "Error", Message = "Template not found" }); - template.Name = model.Name; - template.Description = model.Description; - template.IsActive = model.IsActive; - - _db.TaskListTemplateItems.RemoveRange(template.Items); - - template.Items = model.Items.Select((item, idx) => new TaskListTemplateItem - { - TaskListTemplateId = id, - ItemText = item.ItemText, - SortOrder = item.SortOrder > 0 ? item.SortOrder : idx - }).ToList(); - - await _db.SaveChangesAsync(); return Ok(new { message = "Template updated" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpDelete("{id}")] - public async Task Delete(int id) + public async Task Delete(int id, CancellationToken cancellationToken) { - var template = await _db.TaskListTemplates.FindAsync(id); - if (template == null) + var deleted = await _taskListTemplateService.DeleteAsync(id, cancellationToken); + if (!deleted) return NotFound(new Response { Status = "Error", Message = "Template not found" }); - template.IsActive = false; - await _db.SaveChangesAsync(); return Ok(new { message = "Template deleted" }); } + + private static TaskListTemplateInput MapToInput(TaskListTemplate_DTO model) + { + return new TaskListTemplateInput + { + Name = model.Name, + Description = model.Description, + IsActive = model.IsActive, + Items = model.Items.Select(item => new TaskListTemplateInputItem + { + ItemText = item.ItemText, + SortOrder = item.SortOrder + }).ToList() + }; + } } } diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index 4b90ff2..7cccbb7 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -1,8 +1,10 @@ +using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.Security.Claims; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -12,225 +14,77 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/uplifts")] public class UpliftController : Controller { - private readonly ApplicationDbContext _db; - private readonly IConfiguration _config; + private readonly IUpliftService _upliftService; + private readonly ILogger _logger; - public UpliftController(ApplicationDbContext db, IConfiguration config) + public UpliftController(IUpliftService upliftService, ILogger logger) { - _db = db; - _config = config; + _upliftService = upliftService; + _logger = logger; } [HttpGet] - public async Task List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25) + public async Task List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25, CancellationToken cancellationToken = default) { - var query = from u in _db.DispatchUpliftRequests - join d in _db.Dispatches on u.DispatchId equals d.Id - join v in _db.Vendors on d.VendorId equals v.Id into vendors - from v in vendors.DefaultIfEmpty() - where (u.IsDeleted == null || u.IsDeleted == false) - select new { u, d, v }; - - if (!string.IsNullOrWhiteSpace(status)) - query = query.Where(x => x.u.Status == status); - if (tier.HasValue) - query = query.Where(x => x.u.RequiredTier == tier.Value); - - var total = await query.CountAsync(); - - var raw = await query - .OrderByDescending(x => x.u.CreatedDate) - .Skip((Math.Max(page, 1) - 1) * pageSize) - .Take(pageSize) - .Select(x => new - { - x.u.Id, - DispatchId = x.d.Id, - x.d.DispatchNumber, - x.d.PONumber, - WorkOrderId = x.d.WorkOrderId, - VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, - x.u.CurrentNTE, - x.u.RequestedNTE, - x.u.VendorReason, - x.u.RequiredTier, - x.u.Status, - RequestedAt = x.u.CreatedDate, - x.u.DecidedAt, - x.u.DecisionNote - }) - .ToListAsync(); - - var items = raw.Select(r => new - { - r.Id, - r.DispatchId, - r.DispatchNumber, - r.PONumber, - r.WorkOrderId, - r.VendorCompanyName, - r.CurrentNTE, - r.RequestedNTE, - Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), - r.VendorReason, - r.RequiredTier, - r.Status, - r.RequestedAt, - r.DecidedAt, - r.DecisionNote, - CanDecide = UserCanApprove(r.RequiredTier) - }).ToList(); - - return Ok(new DataResponse - { - Status = "Success", - Data = new { total, page, pageSize, items } - }); + var result = await _upliftService.ListAsync(User, status, tier, page, pageSize, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } [HttpGet("dispatch/{dispatchId:int}")] - public async Task ListForDispatch(int dispatchId) + public async Task ListForDispatch(int dispatchId, CancellationToken cancellationToken = default) { - var rows = await (from u in _db.DispatchUpliftRequests - where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) - join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs - from dec in decs.DefaultIfEmpty() - orderby u.CreatedDate descending - select new - { - u.Id, - u.DispatchId, - u.CurrentNTE, - u.RequestedNTE, - Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m), - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - RequestedAt = u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null - }).ToListAsync(); - - var items = rows.Select(r => new - { - r.Id, - r.DispatchId, - r.CurrentNTE, - r.RequestedNTE, - r.Delta, - r.VendorReason, - r.Status, - r.RequiredTier, - r.RequestedByVendorName, - r.RequestedAt, - r.DecidedAt, - r.DecisionNote, - DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), - CanDecide = UserCanApprove(r.RequiredTier) - }).ToList(); - + var items = await _upliftService.ListForDispatchAsync(User, dispatchId, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = items }); } [HttpPost("{id:int}/approve")] - public async Task Approve(int id, [FromBody] DecisionRequest? body) + public async Task Approve(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default) { - var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot approve a '{req.Status}' request" }); - if (!UserCanApprove(req.RequiredTier)) - return StatusCode(403, new Response { Status = "Error", Message = $"Approval requires a Tier {req.RequiredTier} role" }); - - var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") - return BadRequest(new Response { Status = "Error", Message = $"Cannot approve uplift on a '{dispatch.Status}' dispatch" }); - - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var now = DateTime.UtcNow; - var oldNTE = dispatch.NTEAmount ?? 0m; - - dispatch.NTEAmount = req.RequestedNTE; - dispatch.LastModificationTime = now; - - req.Status = "Approved"; - req.DecidedAt = now; - req.DecidedByUserId = userId; - req.DecisionNote = string.IsNullOrWhiteSpace(body?.Note) ? null : body!.Note!.Trim(); - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + try { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", - OldValue = $"${oldNTE:F2}", - NewValue = $"${req.RequestedNTE:F2}", - Action = "uplift_approved", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status, dispatch.NTEAmount } }); + var result = await _upliftService.ApproveAsync(User, id, body?.Note, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action on this uplift request") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be modified in its current state") }); + } } [HttpPost("{id:int}/deny")] - public async Task Deny(int id, [FromBody] DecisionRequest body) + public async Task Deny(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default) { - if (body == null || string.IsNullOrWhiteSpace(body.Note)) - return BadRequest(new Response { Status = "Error", Message = "A note is required when denying" }); - - var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot deny a '{req.Status}' request" }); - if (!UserCanApprove(req.RequiredTier)) - return StatusCode(403, new Response { Status = "Error", Message = $"Decision requires a Tier {req.RequiredTier} role" }); - - var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId); - - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var now = DateTime.UtcNow; - - req.Status = "Denied"; - req.DecidedAt = now; - req.DecidedByUserId = userId; - req.DecisionNote = body.Note!.Trim(); - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + try { - WorkOrderId = dispatch?.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", - OldValue = "Pending", - NewValue = "Denied", - Action = "uplift_denied", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); + var result = await _upliftService.DenyAsync(User, id, body?.Note, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action on this uplift request") }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be modified in its current state") }); + } } [HttpGet("can-approve")] public IActionResult CanApprove([FromQuery] int tier) { - return Ok(new DataResponse { Status = "Success", Data = new { canApprove = UserCanApprove(tier) } }); - } - - private bool UserCanApprove(int requiredTier) - { - var key = $"Approvals:Tier{requiredTier}Roles"; - var roles = _config.GetSection(key).Get() ?? Array.Empty(); - foreach (var r in roles) - { - if (!string.IsNullOrWhiteSpace(r) && User.IsInRole(r)) return true; - } - return false; + return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, tier) } }); } public class DecisionRequest diff --git a/Api.SeaHavenIndustries/Controllers/UserController.cs b/Api.SeaHavenIndustries/Controllers/UserController.cs index 375649c..1540c5c 100644 --- a/Api.SeaHavenIndustries/Controllers/UserController.cs +++ b/Api.SeaHavenIndustries/Controllers/UserController.cs @@ -1,15 +1,12 @@ using Api.SeaHavenIndustries.DTOs; using Api.SeaHavenIndustries.Helper; -using Api.SeaHavenIndustries.Models; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using PasswordGenerator; -using SendGrid.Helpers.Mail; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; using System.Security.Claims; -using System.Security.Cryptography; namespace Api.SeaHavenIndustries.Controllers { @@ -18,287 +15,123 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/User")] public class UserController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - private readonly SendMessage _sendemail; + private readonly IUserService _userService; + private readonly ILogger _logger; - public UserController(UserManager userManager, ApplicationDbContext db, SendMessage sendemail) + public UserController(IUserService userService, ILogger logger) { - _userManager = userManager; - _db = db; - _sendemail = sendemail; + _userService = userService; + _logger = logger; } [Route("")] [HttpGet] - public async Task GetUsers() + public async Task GetUsers(CancellationToken cancellationToken) { - _db.ChangeTracker.Clear(); - - var allUsers = _db.Users.Select(s => new UserRoleViewModel - { - RoleName = s.PhoneNumber, - Email = s.Email, - Name = s.FirstName, - Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "", - Status = s.UniqueName, - Id = s.Id - }).AsQueryable(); - return Ok(allUsers); + var users = await _userService.GetUsersAsync(cancellationToken); + return Ok(users); } [HttpPost] [Route("AddUser")] - public async Task AddUser(User_DTO user) + public async Task AddUser(User_DTO user, CancellationToken cancellationToken) { - - var model = new ApplicationUser - { - UserName = user.Email, - FirstName = user.Name, - Email = user.Email, - }; - try { - _db.ChangeTracker.Clear(); - var exist = _db.Set().Find(model.Id); - if (exist == null) + var dto = new AddUserRequestDTO { - model.EmailConfirmed = true; - model.UserName = model.Email; - model.CreatedDate = DateTime.Now; - model.UniqueName = "Active"; - model.PhoneNumber = user.Role; - - var pwd = new Password(8).IncludeLowercase().IncludeUppercase().IncludeSpecial().IncludeNumeric(); - var password = pwd.Next(); - - var result = await _userManager.CreateAsync(model, password); - if (result.Succeeded) - { - await _userManager.AddToRoleAsync(model, user.Role ?? ""); - } - else - { - return BadRequest(new Response { Status = "Error", Message = result.Errors.FirstOrDefault()?.Description ?? "error" }); - } - var request = HttpContext?.Request; - //var domain = $"{request?.Scheme}://{request?.Host}"; - var domain = "http://shoc-ui-app.s3-website-us-east-1.amazonaws.com/#"; - string subject = "Login Information"; - string greeting = $"Dear {model.FirstName},\n\n"; - string loginLink = $"Click here to login:Login\n"; - string passwordInfo = $"Your password is: {password}\n\n"; - string body = $"{greeting}\n\n{loginLink}\n\n{passwordInfo}"; - await _sendemail.SendEMail(model?.Email ?? "", subject, body); - } - else + Name = user.Name, + Email = user.Email, + Contact = user.Contact, + Role = user.Role + }; + var outcome = await _userService.AddUserAsync(dto, cancellationToken); + if (!outcome.Success) { - - var exist1 = await _userManager.Users.FirstOrDefaultAsync(w => w.Id == model.Id); - if (exist1 == null) - return BadRequest(new Response { Status = "Error", Message = "User not found" }); - - var existingRole = await _userManager.GetRolesAsync(exist1); - - // Remove from roles if any exist - if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber) - { - await _userManager.RemoveFromRolesAsync(exist1, existingRole); - } - - // Update user details - exist1.FirstName = model.FirstName; - exist1.LastName = model.LastName; - exist1.Contact = model.Contact; - exist1.PhoneNumber = model.PhoneNumber; - - // Optionally, detach the entity if you're about to attach it again - _db.Entry(exist1).State = EntityState.Detached; - - // Add to role after setting properties - await _userManager.AddToRoleAsync(exist1, user.Role ?? ""); - var result = await _userManager.UpdateAsync(exist1); - //await _context.SaveChangesAsync(); + return BadRequest(new Response { Status = "Error", Message = outcome.Error }); } return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpPut] [Route("EditUser")] - public async Task EditUser(EditUser_DTO user) + public async Task EditUser(EditUser_DTO user, CancellationToken cancellationToken) { try { - var exist = _db.Users.Where(w => w.Id == user.Id).FirstOrDefault(); - if (exist == null) - return BadRequest(new Response { Status = "Error", Message = "User not found" }); - exist.FirstName = user.Name; - exist.EmailConfirmed = true; - exist.UserName = user.Email; - exist.Email = user.Email; - exist.NormalizedEmail = user.Email.ToUpper(); - exist.NormalizedUserName = user.Email.ToUpper(); - exist.CreatedDate = DateTime.Now; - exist.UniqueName = "Active"; - exist.PhoneNumber = user.Role; - var existingRole = await _userManager.GetRolesAsync(exist); - - if (existingRole != null && existingRole.Any()) + var dto = new EditUserRequestDTO { - await _userManager.RemoveFromRolesAsync(exist, existingRole); - } - await _userManager.AddToRoleAsync(exist, user.Role ?? ""); - - await _db.SaveChangesAsync(); + Id = user.Id, + Name = user.Name, + Email = user.Email, + Role = user.Role + }; + var succeeded = await _userService.EditUserAsync(dto, cancellationToken); + if (!succeeded) + return BadRequest(new Response { Status = "Error", Message = "User not found" }); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } - public class DeleteUserRequest - { - public string Id { get; set; } - } - [HttpDelete] [Route("DeleteUser")] - public async Task DeleteUser(EditUser_DTO req) + public async Task DeleteUser(EditUser_DTO req, CancellationToken cancellationToken) { - string id = req.Id; + string id = req.Id ?? ""; try { - var data = await _db.Users.Where(w => w.Id == id).FirstOrDefaultAsync(); - - if (data != null) - { - var existworkorder = _db.workOrders.Where(w => w.AssignTo.Contains(data.Id)).ToList(); - foreach (var workorder in existworkorder) - { - var existdata = _db.workOrders.Where(w => w.Id == workorder.Id).FirstOrDefault(); - existdata.AssignTo = null; - } - var existworkordercomments = _db.Comments.Where(w => w.UserId.Contains(data.Id)).ToList(); - foreach (var workorderc in existworkordercomments) - { - var existdatacomm = _db.Comments.Where(w => w.Id == workorderc.Id).FirstOrDefault(); - existdatacomm.UserId = null; - } - var existtemplate = _db.Templates.Where(w => w.AssignTo.Contains(data.Id)).ToList(); - foreach (var workordert in existtemplate) - { - var existdattem = _db.Templates.Where(w => w.Id == workordert.Id).FirstOrDefault(); - existdattem.AssignTo = null; - } - var existtempexistuserrolelate = _db.UserRoles.Where(w => w.UserId.Contains(data.Id)).ToList(); - if (existtempexistuserrolelate.Count() > 0) - { - _db.UserRoles.RemoveRange(existtempexistuserrolelate); - } - _db.Users.Remove(data); - } - else + var found = await _userService.DeleteUserAsync(id, cancellationToken); + if (!found) { return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); } - await _db.SaveChangesAsync(); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpPost] [Route("DeleteCurrentUser")] - public async Task DeleteCurrentUser() + public async Task DeleteCurrentUser(CancellationToken cancellationToken) { try { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var exist = _db.Users.Where(w => w.Id == userid).FirstOrDefault(); - if(exist != null) - { - exist.IsDeleted = true; - } - await _db.SaveChangesAsync(); + await _userService.DeleteCurrentUserAsync(userid, cancellationToken); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet] [Route("UserProfile")] - public async Task UserProfile() + public async Task UserProfile(CancellationToken cancellationToken) { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var data = await _db.Users.Where(w => w.Id == userid).Select(s => new + var data = await _userService.GetUserProfileAsync(userid, cancellationToken); + return Ok(data.Select(d => new { - s.Id, - Name = s.FirstName, - addeddate = s.CreatedDate, - s.Email, - s.Contact - }).ToListAsync(); - return Ok(data); - } - public static string GenerateRandomPassword(int length = 8) - { - const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?"; - const string capitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - const string digits = "1234567890"; - const string symbols = "!@#$%^&*()_-+=<>?"; - - using (var rng = RandomNumberGenerator.Create()) - { - var passwordChars = new char[length]; - - // Ensure at least one capital letter - passwordChars[0] = capitalLetters[GetNextNonNegativeIndex(rng, capitalLetters.Length)]; - - // Ensure at least one digit - passwordChars[1] = digits[GetNextNonNegativeIndex(rng, digits.Length)]; - - // Ensure at least one symbol - passwordChars[2] = symbols[GetNextNonNegativeIndex(rng, symbols.Length)]; - - // Fill the rest of the password with random characters - for (int i = 3; i < length; i++) - { - passwordChars[i] = validChars[GetNextNonNegativeIndex(rng, validChars.Length)]; - } - - // Shuffle the password characters, excluding the initial characters - Random random = new Random(); - for (int i = length - 1; i > 2; i--) - { - int j = random.Next(2, i + 1); - (passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]); - } - - return new string(passwordChars); - } - } - - private static int GetNextNonNegativeIndex(RandomNumberGenerator rng, int maxValue) - { - byte[] randomBytes = new byte[4]; - rng.GetBytes(randomBytes); - int nextIndex = BitConverter.ToInt32(randomBytes, 0) % maxValue; - return (nextIndex + maxValue) % maxValue; // Ensure the index is non-negative + d.Id, + Name = d.Name, + addeddate = d.AddedDate, + d.Email, + d.Contact + })); } } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 8208af3..06251bc 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -1,9 +1,10 @@ using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; using System.Security.Claims; @@ -17,18 +18,12 @@ namespace Api.SeaHavenIndustries.Controllers public class VendorController : Controller { private readonly IVendorService _vendorService; - private readonly ApplicationDbContext _db; // Keep for complex queries like nearby vendors - private readonly Helper.ZipCodeDistance _zipDistance; - private readonly Helper.VendorPortalTokenService _vendorTokens; - private readonly IConfiguration _config; + private readonly ILogger _logger; - public VendorController(IVendorService vendorService, ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config) + public VendorController(IVendorService vendorService, ILogger logger) { _vendorService = vendorService; - _db = db; - _zipDistance = zipDistance; - _vendorTokens = vendorTokens; - _config = config; + _logger = logger; } [HttpGet("GetVendorList")] @@ -157,7 +152,7 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -208,7 +203,7 @@ namespace Api.SeaHavenIndustries.Controllers return Conflict(new { Status = "Conflict", - Message = dbex.Message, + Message = _logger.Sanitize(dbex, "Vendor cannot be deactivated because it has open work orders"), OpenWorkOrders = dbex.OpenWorkOrders }); } @@ -218,7 +213,7 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -245,7 +240,7 @@ namespace Api.SeaHavenIndustries.Controllers return Conflict(new { Status = "Conflict", - Message = dbex.Message, + Message = _logger.Sanitize(dbex, "Vendor cannot be deactivated because it has open work orders"), OpenWorkOrders = dbex.OpenWorkOrders }); } @@ -255,7 +250,7 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -273,7 +268,7 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -313,165 +308,46 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet("Dropdown")] - public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null) + public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null, CancellationToken cancellationToken = default) { - var query = _db.Vendors.Where(v => v.IsActive); - - var vendors = await query.OrderBy(v => v.CompanyName) - .Select(v => new - { - v.Id, - v.CompanyName, - v.ContactName, - v.PreferredContact, - - v.TradeSpecialties, - v.Address, - v.City, - v.State, - v.Zip - }) - .ToListAsync(); - - var result = vendors.Select(v => - { - var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip); - var addr = new[] { v.Address, v.City, v.State, v.Zip } - .Where(s => !string.IsNullOrWhiteSpace(s)); - return new - { - v.Id, - v.CompanyName, - v.ContactName, - v.PreferredContact, - - v.TradeSpecialties, - address = string.Join(", ", addr), - distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null - }; - }).ToList(); - - if (!string.IsNullOrWhiteSpace(trade)) - { - var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList(); - var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList(); - result = tradeMatched.Concat(rest).ToList(); - } - - if (!string.IsNullOrWhiteSpace(siteZip)) - { - result = result.OrderBy(v => v.distanceMiles ?? 99999).ToList(); - } - + var result = await _vendorService.GetDropdownAsync(trade, siteZip, cancellationToken); return Ok(result); } [HttpGet("facets")] - public async Task GetFacets([FromQuery] bool? isActive = null) + public async Task GetFacets([FromQuery] bool? isActive = null, CancellationToken cancellationToken = default) { - var query = _db.Vendors - .AsNoTracking() - .Where(v => v.IsDeleted == null || v.IsDeleted == false); - - if (isActive.HasValue) - query = query.Where(v => v.IsActive == isActive.Value); - - var vendors = await query - .OrderByDescending(v => v.Id) - .ToListAsync(); - - var companies = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) - .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(v => v.CompanyName) - .Select(v => new - { - Name = v.CompanyName, - v.CompanyId, - v.CompanyPhone, - v.Email, - v.PreferredContact, - v.Address, - v.City, - v.State, - Zip = v.Zip, - v.GoogleMapsUrl - }); - - var trades = vendors - .SelectMany(v => (v.TradeSpecialties ?? "") - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(value => value); - - var locations = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) - .Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) - .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) - .OrderBy(location => location.Label); - - return Ok(new - { - Companies = companies, - Trades = trades, - Locations = locations, - JobBuckets = new[] - { - new { Id = "under-50", Label = "Under 50" }, - new { Id = "50-99", Label = "50–99" }, - new { Id = "100-149", Label = "100–149" }, - new { Id = "150-plus", Label = "150+" } - } - }); + var result = await _vendorService.GetFacetsAsync(isActive, cancellationToken); + return Ok(result); } [HttpGet("{id:int}/portal-token")] - public async Task GetPortalToken(int id) + public async Task GetPortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - var token = await _vendorTokens.GetOrCreateActiveTokenAsync(id); - return Ok(BuildPortalTokenResponse(token)); + var result = await _vendorService.GetPortalTokenAsync(id, cancellationToken); + if (result == null) return NotFound(); + return Ok(result); } [HttpPost("{id:int}/portal-token/rotate")] - public async Task RotatePortalToken(int id) + public async Task RotatePortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - var token = await _vendorTokens.RotateAsync(id); - return Ok(BuildPortalTokenResponse(token)); + var result = await _vendorService.RotatePortalTokenAsync(id, cancellationToken); + if (result == null) return NotFound(); + return Ok(result); } [HttpPost("{id:int}/portal-token/revoke")] - public async Task RevokePortalToken(int id) + public async Task RevokePortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - await _vendorTokens.RevokeAllAsync(id); + var success = await _vendorService.RevokePortalTokenAsync(id, cancellationToken); + if (!success) return NotFound(); return Ok(new { revoked = true }); } - - private object BuildPortalTokenResponse(VendorAccessToken token) - { - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - return new - { - token.Token, - token.IssuedAt, - token.ExpiresAt, - token.LastUsedAt, - PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard" - }; - } } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs b/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs index 5dd93cc..9b46315 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs @@ -1,11 +1,10 @@ -using System.Security.Claims; using System.Text; using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { @@ -14,94 +13,33 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/vendor-operations")] public class VendorOperationsController : ControllerBase { - private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" }; - private readonly ApplicationDbContext _db; - private readonly IDispatchDataService _dispatchDataService; - private readonly TimeProvider _timeProvider; + private readonly IVendorOperationsService _service; - public VendorOperationsController( - ApplicationDbContext db, - IDispatchDataService dispatchDataService, - TimeProvider timeProvider) + public VendorOperationsController(IVendorOperationsService service) { - _db = db; - _dispatchDataService = dispatchDataService; - _timeProvider = timeProvider; + _service = service; } - [HttpGet("notifications")] - public async Task GetNotifications() + private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.Identity?.Name + ?? "unknown"; + + private IActionResult Map(VendorOperationOutcome outcome) => outcome.Kind switch { - var now = _timeProvider.GetUtcNow().UtcDateTime; - var cutoff = now.AddHours(48); + VendorOperationOutcomeKind.Ok => Ok(outcome.Value), + VendorOperationOutcomeKind.NotFound => outcome.Message is null + ? NotFound() + : NotFound(new Response { Status = "Error", Message = outcome.Message }), + VendorOperationOutcomeKind.BadRequest => BadRequest(new Response { Status = "Error", Message = outcome.Message }), + VendorOperationOutcomeKind.Conflict => Conflict(new Response { Status = "Conflict", Message = outcome.Message }), + _ => StatusCode(StatusCodes.Status500InternalServerError) + }; - var directlyAssignedIds = await _db.Dispatches - .AsNoTracking() - .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue) - .Select(d => d.WorkOrderId!.Value) - .ToListAsync(); - var multiplyAssignedIds = await _db.DispatchWorkOrders - .AsNoTracking() - .Where(link => link.Dispatch != null - && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) - .Select(link => link.WorkOrderId) - .ToListAsync(); - var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList(); - - var noVendor = await _db.workOrders - .AsNoTracking() - .Where(wo => wo.ScheduledDate.HasValue - && wo.ScheduledDate.Value <= cutoff - && wo.LifecycleStatus != LifecycleStatus.Completed - && wo.LifecycleStatus != LifecycleStatus.Canceled - && !assignedWorkOrderIds.Contains(wo.Id)) - .OrderBy(wo => wo.ScheduledDate) - .Select(wo => new - { - Id = $"no-vendor-{wo.Id}", - Type = "NoVendor", - Severity = "Medium", - Group = "No Vendor", - Title = "No vendor assigned — 48h to service", - WorkOrderId = wo.Id, - WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber, - TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48), - ScheduledAt = wo.ScheduledDate, - Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" } - }) - .ToListAsync(); - - var dispatches = await ActiveScheduledDispatches().ToListAsync(); - var conflicts = BuildConflictPairs(dispatches) - .Select(pair => new - { - Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}", - Type = "VendorConflict", - Severity = "Medium", - Group = "Vendor Conflict", - Title = "Vendor has overlapping appointments", - VendorId = pair.Left.VendorId, - VendorName = pair.Left.VendorName, - WorkOrders = new[] - { - new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End }, - new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End } - }, - TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start, - Actions = new[] - { - new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" }, - new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" } - } - }) - .ToList(); - - return Ok(new - { - GeneratedAt = now, - Items = noVendor.Cast().Concat(conflicts).OrderBy(item => - item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList() - }); + [HttpGet("notifications")] + public async Task GetNotifications(CancellationToken cancellationToken) + { + var result = await _service.GetNotificationsAsync(cancellationToken); + return Ok(new { result.GeneratedAt, result.Items }); } [HttpGet("availability")] @@ -109,342 +47,53 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] DateTime? start, [FromQuery] DateTime? end, [FromQuery] int? locationId, - [FromQuery] string? trade) + [FromQuery] string? trade, + CancellationToken cancellationToken) { - var now = _timeProvider.GetUtcNow().UtcDateTime; - var requestedStart = start ?? now; - var requestedEnd = end ?? requestedStart.AddHours(2); - if (requestedEnd <= requestedStart) - return BadRequest(new Response { Status = "Error", Message = "end must be after start" }); - - var vendors = await _db.Vendors.AsNoTracking() - .Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false)) - .ToListAsync(); - - var preferences = locationId.HasValue - ? await _db.SitePreferredVendors.AsNoTracking() - .Where(p => p.LocationId == locationId.Value) - .ToListAsync() - : new List(); - - var dispatches = await ActiveScheduledDispatches().ToListAsync(); - var result = vendors.Select(vendor => - { - var matchingDispatches = dispatches - .Where(dispatch => dispatch.VendorId == vendor.Id - && WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End)) - .ToList(); - var matchingPreference = preferences - .Where(preference => preference.VendorId == vendor.Id - && (string.IsNullOrWhiteSpace(preference.Trade) - || string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase))) - .OrderBy(preference => preference.SortOrder) - .FirstOrDefault(); - var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue - && vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24); - var status = matchingDispatches.Count > 0 - ? "Unavailable" - : signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus) - ? vendor.AvailabilityStatus - : "Unknown"; - - return new - { - vendor.Id, - vendor.CompanyName, - vendor.ContactName, - vendor.TradeSpecialties, - AvailabilityStatus = status, - vendor.AvailabilityUpdatedAt, - IsPreferred = matchingPreference != null, - PreferenceRank = matchingPreference?.SortOrder, - Conflicts = matchingDispatches.Select(dispatch => new - { - dispatch.WorkOrderId, - dispatch.WorkOrderNumber, - dispatch.Start, - dispatch.End - }) - }; - }) - .OrderBy(vendor => vendor.IsPreferred ? 0 : 1) - .ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue) - .ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0 - : vendor.AvailabilityStatus == "Unknown" ? 1 : 2) - .ThenBy(vendor => vendor.CompanyName) - .ToList(); - - return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result }); + var outcome = await _service.GetAvailabilityAsync(start, end, locationId, trade, cancellationToken); + return Map(outcome); } [HttpPut("vendors/{vendorId:int}/availability")] - public async Task UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body) + public async Task UpdateAvailability(int vendorId, [FromBody] VendorAvailabilityRequest body, CancellationToken cancellationToken) { - var normalized = body.Status?.Trim(); - if (normalized is not ("Available" or "Unavailable" or "Unknown")) - return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" }); - - var vendor = await _db.Vendors.FindAsync(vendorId); - if (vendor == null) return NotFound(); - - var now = _timeProvider.GetUtcNow().UtcDateTime; - var actor = Actor(); - var previous = vendor.AvailabilityStatus; - vendor.AvailabilityStatus = normalized; - vendor.AvailabilityUpdatedAt = now; - vendor.LastModificationTime = now; - _db.VendorAuditLogs.Add(new VendorAuditLog - { - VendorId = vendorId, - FieldName = nameof(Vendor.AvailabilityStatus), - OldValue = previous, - NewValue = normalized, - Actor = actor, - CreatedAt = now - }); - await _db.SaveChangesAsync(); - return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt }); + var outcome = await _service.UpdateAvailabilityAsync(vendorId, body?.Status, Actor(), cancellationToken); + return Map(outcome); } [HttpGet("sites/{locationId:int}/preferred-vendors")] - public async Task GetSitePreferences(int locationId) + public async Task GetSitePreferences(int locationId, CancellationToken cancellationToken) { - var preferences = await _db.SitePreferredVendors - .AsNoTracking() - .Where(p => p.LocationId == locationId) - .Include(p => p.Vendor) - .OrderBy(p => p.SortOrder) - .Select(p => new - { - p.Id, - p.LocationId, - p.VendorId, - VendorName = p.Vendor!.CompanyName, - p.Trade, - p.SortOrder, - IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false), - p.LastModificationTime, - p.UpdatedBy - }) - .ToListAsync(); + var preferences = await _service.GetSitePreferencesAsync(locationId, cancellationToken); return Ok(preferences); } [HttpPut("sites/{locationId:int}/preferred-vendors")] - public async Task ReplaceSitePreferences(int locationId, [FromBody] List body) + public async Task ReplaceSitePreferences(int locationId, [FromBody] List body, CancellationToken cancellationToken) { - if (!await _db.Locations.AnyAsync(location => location.Id == locationId)) - return NotFound(new Response { Status = "Error", Message = "Location not found" }); - if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1)) - return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" }); - - var vendorIds = body.Select(item => item.VendorId).Distinct().ToList(); - var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id); - foreach (var item in body) - { - if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false)) - return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" }); - if (!string.IsNullOrWhiteSpace(item.Trade) - && !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) - .Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase)) - return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" }); - } - - var now = _timeProvider.GetUtcNow().UtcDateTime; - var actor = Actor(); - var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync(); - _db.SitePreferredVendors.RemoveRange(existing); - _db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor - { - LocationId = locationId, - VendorId = item.VendorId, - Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(), - SortOrder = item.SortOrder ?? index, - CreatedDate = now, - LastModificationTime = now, - createdby = actor, - UpdatedBy = actor - })); - foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct()) - { - _db.VendorAuditLogs.Add(new VendorAuditLog - { - VendorId = vendorId, - FieldName = $"SitePreference:{locationId}", - OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), - NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), - Actor = actor, - CreatedAt = now - }); - } - await _db.SaveChangesAsync(); - return await GetSitePreferences(locationId); + var outcome = await _service.ReplaceSitePreferencesAsync(locationId, body ?? new List(), Actor(), cancellationToken); + return Map(outcome); } [HttpPut("documents/{documentId:int}/review")] - public async Task ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body) + public async Task ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body, CancellationToken cancellationToken) { - if (body.ReviewStatus is not ("Approved" or "Rejected")) - return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" }); - if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason)) - return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" }); - - var document = await _db.VendorCompletionDocuments.FindAsync(documentId); - if (document == null) return NotFound(); - if (document.ScanStatus != "Passed") - return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" }); - - var now = _timeProvider.GetUtcNow().UtcDateTime; - var previous = document.ReviewStatus; - document.ReviewStatus = body.ReviewStatus; - document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null; - document.ReviewedAt = now; - document.LastModificationTime = now; - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = document.WorkOrderId, - UserId = Actor(), - FieldName = $"Completion Document {document.OriginalFileName}", - OldValue = previous, - NewValue = document.ReviewStatus, - Action = "completion_document_reviewed", - CreatedAt = now - }); - await _db.SaveChangesAsync(); - return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt }); + var outcome = await _service.ReviewDocumentAsync(documentId, body ?? new DocumentReviewRequest(), Actor(), cancellationToken); + return Map(outcome); } [HttpPut("dispatches/{dispatchId:int}/commercial-status")] - public async Task UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body) + public async Task UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body, CancellationToken cancellationToken) { - var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" }; - var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" }; - if (!invoiceStatuses.Contains(body.InvoiceStatus)) - return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" }); - if (!paymentStatuses.Contains(body.PaymentStatus)) - return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" }); - - var dispatch = await _db.Dispatches.FindAsync(dispatchId); - if (dispatch == null) return NotFound(); - var now = _timeProvider.GetUtcNow().UtcDateTime; - var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}"; - dispatch.InvoiceStatus = body.InvoiceStatus; - dispatch.PaymentStatus = body.PaymentStatus; - dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim(); - dispatch.CommercialStatusUpdatedAt = now; - dispatch.LastModificationTime = now; - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = Actor(), - FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status", - OldValue = oldValue, - NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}", - Action = "commercial_status_updated", - CreatedAt = now - }); - await _db.SaveChangesAsync(); - return Ok(new - { - dispatch.Id, - dispatch.PONumber, - dispatch.InvoiceNumber, - dispatch.InvoiceStatus, - dispatch.PaymentStatus, - dispatch.CommercialStatusUpdatedAt - }); + var outcome = await _service.UpdateCommercialStatusAsync(dispatchId, body ?? new CommercialStatusRequest(), Actor(), cancellationToken); + return Map(outcome); } [HttpPut("work-orders/{workOrderId:int}/assignment")] - public async Task AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body) + public async Task AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body, CancellationToken cancellationToken) { - var workOrder = await _db.workOrders.FindAsync(workOrderId); - if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" }); - var vendor = await _db.Vendors.FindAsync(body.VendorId); - if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false)) - return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" }); - - var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate; - var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2); - var scheduledDispatches = start.HasValue && end.HasValue - ? await ActiveScheduledDispatches() - .Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId) - .ToListAsync() - : new List(); - var conflicts = start.HasValue && end.HasValue - ? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList() - : new List(); - - var linkedDispatchIds = _db.DispatchWorkOrders - .Where(link => link.WorkOrderId == workOrderId) - .Select(link => link.DispatchId); - var dispatch = await _db.Dispatches - .Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id)) - && !TerminalDispatchStatuses.Contains(item.Status ?? "")) - .OrderByDescending(item => item.CreatedDate) - .FirstOrDefaultAsync(); - var now = _timeProvider.GetUtcNow().UtcDateTime; - var previousVendorId = dispatch?.VendorId; - if (dispatch == null) - { - dispatch = new Dispatch - { - WorkOrderId = workOrderId, - VendorId = vendor.Id, - DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(), - PONumber = await _dispatchDataService.GenerateNextPONumberAsync(), - Status = "Sent", - ScheduledDate = start, - EstimatedArrivalAt = body.EstimatedArrivalAt, - EtaManualOverride = body.EtaManualOverride, - DispatchedAt = now, - CreatedDate = now, - createdby = Actor() - }; - _db.Dispatches.Add(dispatch); - } - else - { - dispatch.VendorId = vendor.Id; - dispatch.ScheduledDate = start; - dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt; - dispatch.EtaManualOverride = body.EtaManualOverride; - dispatch.LastModificationTime = now; - } - - workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart; - workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd; - workOrder.ScheduledDate = start ?? workOrder.ScheduledDate; - workOrder.LastModificationTime = now; - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = workOrderId, - UserId = Actor(), - FieldName = "Vendor Assignment", - OldValue = previousVendorId?.ToString(), - NewValue = vendor.Id.ToString(), - Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned", - CreatedAt = now - }); - await _db.SaveChangesAsync(); - - return Ok(new - { - dispatch.Id, - dispatch.VendorId, - VendorName = vendor.CompanyName, - dispatch.EstimatedArrivalAt, - dispatch.EtaManualOverride, - AvailabilityStatus = conflicts.Count > 0 ? "Unavailable" - : vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown", - Conflicts = conflicts.Select(conflict => new - { - conflict.WorkOrderId, - conflict.WorkOrderNumber, - conflict.Start, - conflict.End - }) - }); + var outcome = await _service.AssignVendorAsync(workOrderId, body ?? new VendorAssignmentRequest(), Actor(), cancellationToken); + return Map(outcome); } [HttpGet("insights")] @@ -453,12 +102,13 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, - [FromQuery] int? vendorId) + [FromQuery] int? vendorId, + CancellationToken cancellationToken) { - var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); return Ok(new { - GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime, + GeneratedAt = _service.UtcNow, Filters = new { from, to, locationId, trade, vendorId }, MetricDefinitions = MetricDefinitions(), Data = rows @@ -471,9 +121,10 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, - [FromQuery] int? vendorId) + [FromQuery] int? vendorId, + CancellationToken cancellationToken) { - var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n"); foreach (var row in rows) { @@ -486,7 +137,7 @@ namespace Api.SeaHavenIndustries.Controllers row.OnTimeCompletionRate.ToString("0.####"), row.AverageCycleHours?.ToString("0.##") ?? "")); } - return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv"); + return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.csv"); } [HttpGet("insights.pdf")] @@ -495,131 +146,22 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, - [FromQuery] int? vendorId) + [FromQuery] int? vendorId, + CancellationToken cancellationToken) { - var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); var lines = new List { "Vendor Performance Insights", - $"Generated: {_timeProvider.GetUtcNow():u}", + $"Generated: {_service.UtcNow:u}", $"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}", "Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours." }; lines.AddRange(rows.Take(35).Select(row => $"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h")); - return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf"); + return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.pdf"); } - private IQueryable ActiveScheduledDispatches() - { - var direct = _db.Dispatches.AsNoTracking() - .Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "") - && (dispatch.IsDeleted == null || dispatch.IsDeleted == false) - && dispatch.WorkOrderId.HasValue - && (dispatch.ScheduledDate.HasValue - || (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue))) - .Select(dispatch => new ScheduledDispatch - { - Id = dispatch.Id, - VendorId = dispatch.VendorId, - VendorName = dispatch.Vendor!.CompanyName, - WorkOrderId = dispatch.WorkOrderId!.Value, - WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber, - Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value, - End = dispatch.WorkOrder.ScheduledEnd - ?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2) - }); - - var linked = _db.DispatchWorkOrders.AsNoTracking() - .Where(link => link.Dispatch != null - && link.WorkOrder != null - && link.Dispatch.WorkOrderId != link.WorkOrderId - && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "") - && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) - && (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue)) - .Select(link => new ScheduledDispatch - { - Id = link.DispatchId, - VendorId = link.Dispatch!.VendorId, - VendorName = link.Dispatch.Vendor!.CompanyName, - WorkOrderId = link.WorkOrderId, - WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber, - Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value, - End = link.WorkOrder.ScheduledEnd - ?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2) - }); - - return direct.Concat(linked); - } - - private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List dispatches) - { - for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++) - { - for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++) - { - var left = dispatches[leftIndex]; - var right = dispatches[rightIndex]; - if (left.VendorId == right.VendorId - && left.WorkOrderId != right.WorkOrderId - && WindowsOverlap(left.Start, left.End, right.Start, right.End)) - yield return (left, right); - } - } - } - - private async Task> BuildInsightRows( - DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId) - { - var query = _db.Dispatches.AsNoTracking() - .Include(dispatch => dispatch.Vendor) - .Include(dispatch => dispatch.WorkOrder) - .Where(dispatch => dispatch.Vendor != null); - if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from); - if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to); - if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId); - if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade); - if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId); - - var dispatches = await query.ToListAsync(); - return dispatches.GroupBy(dispatch => dispatch.VendorId) - .Select(group => - { - var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList(); - var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused"); - var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList(); - var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList(); - var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList(); - return new VendorInsightRow - { - VendorId = group.Key, - Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}", - CompletedJobs = completed.Count, - CancellationRefusalRate = Rate(canceled, group.Count()), - OnTimeArrivalRate = Rate( - acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate), - acknowledgedWithSchedule.Count), - OnTimeCompletionRate = Rate( - completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate), - completedWithDue.Count), - AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch => - (dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours) - }; - }) - .OrderBy(row => row.Vendor) - .ToList(); - } - - private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? User.Identity?.Name - ?? "unknown"; - - private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd) - => leftStart < rightEnd && rightStart < leftEnd; - - private static decimal Rate(int numerator, int denominator) - => denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4); - private static string Csv(string value) { var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0]) @@ -664,61 +206,5 @@ namespace Api.SeaHavenIndustries.Controllers builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF"); return Encoding.ASCII.GetBytes(builder.ToString()); } - - public sealed class AvailabilityRequest - { - public string? Status { get; set; } - } - - public sealed class SitePreferenceRequest - { - public int VendorId { get; set; } - public string? Trade { get; set; } - public int? SortOrder { get; set; } - } - - public sealed class DocumentReviewRequest - { - public string ReviewStatus { get; set; } = ""; - public string? RejectionReason { get; set; } - } - - public sealed class CommercialStatusRequest - { - public string InvoiceStatus { get; set; } = "Not Submitted"; - public string PaymentStatus { get; set; } = "Unavailable"; - public string? InvoiceNumber { get; set; } - } - - public sealed class VendorAssignmentRequest - { - public int VendorId { get; set; } - public DateTime? ScheduledStart { get; set; } - public DateTime? ScheduledEnd { get; set; } - public DateTime? EstimatedArrivalAt { get; set; } - public bool EtaManualOverride { get; set; } - } - - private sealed class ScheduledDispatch - { - public int Id { get; set; } - public int VendorId { get; set; } - public string? VendorName { get; set; } - public int WorkOrderId { get; set; } - public string? WorkOrderNumber { get; set; } - public DateTime Start { get; set; } - public DateTime End { get; set; } - } - - private sealed class VendorInsightRow - { - public int VendorId { get; set; } - public string Vendor { get; set; } = ""; - public int CompletedJobs { get; set; } - public decimal CancellationRefusalRate { get; set; } - public decimal OnTimeArrivalRate { get; set; } - public decimal OnTimeCompletionRate { get; set; } - public double? AverageCycleHours { get; set; } - } } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 53fbcb1..447cf30 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -1,8 +1,10 @@ using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -13,826 +15,260 @@ namespace Api.SeaHavenIndustries.Controllers { private const string TokenHeader = "X-Vendor-Token"; - private static readonly string[] VendorViewableStatuses = - new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" }; + private readonly IVendorPortalService _portalService; + private readonly ILogger _logger; - private readonly ApplicationDbContext _db; - private readonly VendorPortalTokenService _tokens; - private readonly IConfiguration _config; - private readonly SendMessage _sendMessage; - private readonly IWebHostEnvironment _environment; - - public VendorPortalController( - ApplicationDbContext db, - VendorPortalTokenService tokens, - IConfiguration config, - SendMessage sendMessage, - IWebHostEnvironment environment) + public VendorPortalController(IVendorPortalService portalService, ILogger logger) { - _db = db; - _tokens = tokens; - _config = config; - _sendMessage = sendMessage; - _environment = environment; + _portalService = portalService; + _logger = logger; } [HttpGet("session")] - public async Task Session() + public async Task Session(CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); return Ok(new DataResponse { Status = "Success", - Data = new - { - vendor.Id, - vendor.CompanyName, - vendor.ContactName, - vendor.Email, - vendor.Phone - } + Data = session }); } [HttpGet("dispatches")] - public async Task ListDispatches([FromQuery] string? status = null) + public async Task ListDispatches([FromQuery] string? status = null, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - - var query = _db.Dispatches - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .Where(d => d.VendorId == vendor.Id && (d.IsDeleted == null || d.IsDeleted == false)); - - if (!string.IsNullOrWhiteSpace(status)) - { - query = query.Where(d => d.Status == status); - } - - var dispatches = await query - .OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate) - .Select(d => new - { - d.Id, - d.DispatchNumber, - d.PONumber, - d.InvoiceNumber, - d.InvoiceStatus, - d.PaymentStatus, - d.Status, - d.NTEAmount, - d.ScheduledDate, - d.CompletedDate, - d.DispatchedAt, - d.AcknowledgedAt, - WorkOrderTitle = d.WorkOrder!.WorkerOrderTitle, - InternalWONumber = d.WorkOrder!.InternalWONumber, - LocationName = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.Name : null, - LocationCity = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.City : null, - LocationState = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.State : null - }) - .ToListAsync(); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var dispatches = await _portalService.ListDispatchesAsync(session, status, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = dispatches }); } [HttpGet("dispatches/{id:int}")] - public async Task GetDispatch(int id) + public async Task GetDispatch(int id, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + var detail = await _portalService.GetDispatchDetailAsync(session, id, cancellationToken); + if (detail == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - var checklist = await _db.DispatchChecklistItems - .Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false)) - .OrderBy(c => c.SortOrder) - .Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt }) - .ToListAsync(); - - var signoffs = await _db.DispatchSignoffs - .Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false)) - .Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt }) - .ToListAsync(); - - var documents = await _db.VendorCompletionDocuments - .Where(document => document.DispatchId == id && document.VendorId == vendor.Id) - .OrderByDescending(document => document.CreatedDate) - .Select(document => new - { - document.Id, - document.OriginalFileName, - document.ContentType, - document.SizeBytes, - document.ScanStatus, - document.ReviewStatus, - document.RejectionReason, - document.Version, - document.ReplacesDocumentId, - document.CreatedDate, - document.ScannedAt, - document.ReviewedAt, - CanDownload = document.ScanStatus == "Passed" - }) - .ToListAsync(); - - var rawUplifts = await (from u in _db.DispatchUpliftRequests - where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false) - join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs - from dec in decs.DefaultIfEmpty() - orderby u.CreatedDate descending - select new - { - u.Id, - u.CurrentNTE, - u.RequestedNTE, - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null - }) - .ToListAsync(); - - var upliftRequests = rawUplifts.Select(u => new - { - u.Id, - u.CurrentNTE, - u.RequestedNTE, - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - RequestedAt = u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() - }).ToList(); - - var rawComments = await (from c in _db.Comments - where c.DispatchId == id - && c.CommentType != "internal" - && (c.IsDeleted == null || c.IsDeleted == false) - join u in _db.Users on c.UserId equals u.Id into users - from u in users.DefaultIfEmpty() - orderby c.CreatedDate - select new - { - c.Id, - c.Commenttext, - c.Commenter, - c.CommentType, - c.CreatedDate, - c.UserId, - UserFirstName = u != null ? u.FirstName : null, - UserLastName = u != null ? u.LastName : null - }) - .ToListAsync(); - - var comments = rawComments.Select(c => - { - var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); - var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); - - string resolvedType; - string resolvedCommenter; - if (hasShocUser) - { - resolvedType = "dispatcher"; - resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName - : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! - : "Dispatcher"; - } - else if (c.CommentType == "customer") - { - resolvedType = "customer"; - resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; - } - else - { - resolvedType = "vendor"; - resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : vendor.CompanyName ?? "Vendor"; - } - - return new - { - c.Id, - c.Commenttext, - Commenter = resolvedCommenter, - CommentType = resolvedType, - c.CreatedDate - }; - }).ToList(); - - return Ok(new DataResponse - { - Status = "Success", - Data = new - { - dispatch.Id, - dispatch.DispatchNumber, - dispatch.PONumber, - dispatch.InvoiceNumber, - dispatch.InvoiceStatus, - dispatch.PaymentStatus, - dispatch.CommercialStatusUpdatedAt, - dispatch.Status, - dispatch.NTEAmount, - dispatch.Description, - dispatch.ScheduledDate, - dispatch.CompletedDate, - dispatch.DispatchedAt, - dispatch.AcknowledgedAt, - WorkOrder = dispatch.WorkOrder == null ? null : new - { - dispatch.WorkOrder.Id, - dispatch.WorkOrder.InternalWONumber, - dispatch.WorkOrder.WorkerOrderTitle, - dispatch.WorkOrder.Description, - dispatch.WorkOrder.Priority, - dispatch.WorkOrder.DueDate, - dispatch.WorkOrder.Trade, - dispatch.WorkOrder.SubTrade, - dispatch.WorkOrder.Problem - }, - Location = dispatch.WorkOrder?.Locations == null ? null : new - { - dispatch.WorkOrder.Locations.Name, - dispatch.WorkOrder.Locations.Address1, - dispatch.WorkOrder.Locations.City, - dispatch.WorkOrder.Locations.State, - dispatch.WorkOrder.Locations.Zip - }, - Checklist = checklist, - Signoffs = signoffs, - Documents = documents, - Comments = comments, - UpliftRequests = upliftRequests - } - }); + return Ok(new DataResponse { Status = "Success", Data = detail }); } [HttpPost("dispatches/{id:int}/accept")] - public async Task Accept(int id) + public async Task Accept(int id, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status != "Sent") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot accept a dispatch with status '{dispatch.Status}'" }); + var result = await _portalService.AcceptDispatchAsync(session, id, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - dispatch.Status = "Acknowledged"; - dispatch.AcknowledgedAt = now; - dispatch.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + catch (KeyNotFoundException) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Status", - OldValue = "Sent", - NewValue = "Acknowledged", - Action = "vendor_accept", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.AcknowledgedAt } }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } } [HttpPost("dispatches/{id:int}/status")] - public async Task ChangeStatus(int id, [FromBody] ChangeStatusRequest body) + public async Task ChangeStatus(int id, [FromBody] ChangeStatusRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - var from = dispatch.Status; - var to = body.Status; - if (!IsAllowedVendorTransition(from, to)) + try { - return BadRequest(new Response { Status = "Error", Message = $"Transition from '{from}' to '{to}' is not allowed" }); + var result = await _portalService.ChangeStatusAsync(session, id, body?.Status, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - dispatch.Status = to; - dispatch.LastModificationTime = now; - if (to == "Completed") dispatch.CompletedDate = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + catch (KeyNotFoundException) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Status", - OldValue = from, - NewValue = to, - Action = "vendor_status_change", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.CompletedDate } }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } } [HttpPost("dispatches/{id:int}/request-cancel")] - public async Task RequestCancel(int id, [FromBody] RequestCancelRequest body) + public async Task RequestCancel(int id, [FromBody] RequestCancelRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot request cancel on a '{dispatch.Status}' dispatch" }); + await _portalService.RequestCancelAsync(session, id, body?.Reason, cancellationToken); + return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" }); } - - var now = DateTime.UtcNow; - var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim(); - - _db.Comments.Add(new Comments + catch (KeyNotFoundException) { - DispatchId = id, - WorkerOrderId = dispatch.WorkOrderId, - Commenter = vendor.CompanyName, - CommentType = "vendor", - RecordType = "cancel_request", - Commenttext = $"Vendor requested cancellation: {reason}", - CreatedDate = now - }); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber}", - OldValue = dispatch.Status, - NewValue = "Cancel Requested", - Action = "vendor_request_cancel", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } } [HttpPost("dispatches/{id:int}/checklist/{itemId:int}")] - public async Task UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body) + public async Task UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = "Dispatch is locked" }); + var result = await _portalService.UpdateChecklistItemAsync(session, id, itemId, body.IsCompleted, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var item = await _db.DispatchChecklistItems - .FirstOrDefaultAsync(c => c.Id == itemId && c.DispatchId == id); - if (item == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); - - var now = DateTime.UtcNow; - item.IsCompleted = body.IsCompleted; - item.CompletedBy = body.IsCompleted ? vendor.CompanyName : null; - item.CompletedAt = body.IsCompleted ? now : null; - item.LastModificationTime = now; - - await _db.SaveChangesAsync(); - return Ok(new DataResponse + catch (KeyNotFoundException) { - Status = "Success", - Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt } - }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } } [HttpPost("dispatches/{id:int}/signoff")] - public async Task AddSignoff(int id, [FromBody] SignoffRequest body) + public async Task AddSignoff(int id, [FromBody] SignoffRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") + try { - return BadRequest(new Response { Status = "Error", Message = "Signoffs only allowed on In Progress or Completed dispatches" }); + var result = await _portalService.AddSignoffAsync(session, id, body?.SignoffType, body?.Name, body?.Signature, body?.SignatureMethod, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature)) + catch (KeyNotFoundException) { - return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - - var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant(); - if (signoffType != "vendor" && signoffType != "customer") + catch (InvalidOperationException ex) { - return BadRequest(new Response { Status = "Error", Message = "signoffType must be 'vendor' or 'customer'" }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); } - - var existing = await _db.DispatchSignoffs - .FirstOrDefaultAsync(s => s.DispatchId == id && s.SignoffType == signoffType); - if (existing != null) - { - return BadRequest(new Response { Status = "Error", Message = $"A {signoffType} signoff already exists for this dispatch" }); - } - - var now = DateTime.UtcNow; - var signoff = new DispatchSignoff - { - DispatchId = id, - SignoffType = signoffType, - Name = body!.Name, - Signature = body.Signature, - SignatureMethod = body.SignatureMethod ?? "drawn", - SignedAt = now, - CreatedDate = now - }; - _db.DispatchSignoffs.Add(signoff); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", - OldValue = null, - NewValue = $"{(signoffType == "customer" ? "Customer" : "Vendor")}: {body.Name}", - Action = signoffType == "customer" ? "customer_signoff" : "vendor_signoff", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { signoff.Id, signoff.SignedAt } }); } [HttpPost("dispatches/{id:int}/comments")] - public async Task AddComment(int id, [FromBody] CommentRequest body) + public async Task AddComment(int id, [FromBody] CommentRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (string.IsNullOrWhiteSpace(body?.CommentText)) + try { - return BadRequest(new Response { Status = "Error", Message = "Comment cannot be empty" }); + var result = await _portalService.AddCommentAsync(session, id, body?.CommentText, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - var comment = new Comments + catch (KeyNotFoundException) { - DispatchId = id, - WorkerOrderId = dispatch.WorkOrderId, - Commenter = vendor.CompanyName, - CommentType = "vendor", - RecordType = "comment", - Commenttext = body!.CommentText, - CreatedDate = now - }; - _db.Comments.Add(comment); - await _db.SaveChangesAsync(); - - return Ok(new DataResponse + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) { - Status = "Success", - Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate } - }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } } [HttpPost("dispatches/{id:int}/uplift-request")] - public async Task RequestUplift(int id, [FromBody] UpliftRequestBody body) + public async Task RequestUplift(int id, [FromBody] UpliftRequestBody body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot request uplift on a '{dispatch.Status}' dispatch" }); + var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - if (body == null || body.RequestedNTE <= 0) + catch (KeyNotFoundException) { - return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - - var current = dispatch.NTEAmount ?? 0m; - if (body.RequestedNTE <= current) + catch (InvalidOperationException ex) { - return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than the current NTE" }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); } - - var pendingExists = await _db.DispatchUpliftRequests - .AnyAsync(u => u.DispatchId == id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false)); - if (pendingExists) - { - return BadRequest(new Response { Status = "Error", Message = "A pending uplift request already exists for this dispatch" }); - } - - var tier1Max = _config.GetValue("Approvals:UpliftTier1MaxUsd") ?? 2500m; - var delta = body.RequestedNTE - current; - var requiredTier = delta > tier1Max ? 2 : 1; - - var now = DateTime.UtcNow; - var req = new DispatchUpliftRequest - { - DispatchId = id, - CurrentNTE = current, - RequestedNTE = body.RequestedNTE, - VendorReason = string.IsNullOrWhiteSpace(body.Reason) ? null : body.Reason!.Trim(), - Status = "Pending", - RequiredTier = requiredTier, - RequestedByVendorName = vendor.CompanyName, - CreatedDate = now - }; - _db.DispatchUpliftRequests.Add(req); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", - OldValue = $"${current:F2}", - NewValue = $"${body.RequestedNTE:F2}", - Action = "uplift_requested", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - - await NotifyDispatcherOfUpliftAsync(dispatch, req, vendor); - - return Ok(new DataResponse - { - Status = "Success", - Data = new { req.Id, req.Status, req.RequiredTier, req.CurrentNTE, req.RequestedNTE } - }); } [HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/cancel")] - public async Task CancelUpliftRequest(int id, int requestId) + public async Task CancelUpliftRequest(int id, int requestId, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - var req = await _db.DispatchUpliftRequests - .FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - - if (req.Status != "Pending") - { - return BadRequest(new Response { Status = "Error", Message = $"Cannot cancel a '{req.Status}' uplift request" }); - } - - var now = DateTime.UtcNow; - req.Status = "Cancelled"; - req.DecidedAt = now; - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", - OldValue = "Pending", - NewValue = "Cancelled", - Action = "uplift_cancelled", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); - } - - [HttpPost("dispatches/{id:int}/documents")] - [RequestSizeLimit(10 * 1024 * 1024)] - public async Task UploadCompletionDocument( - int id, - [FromForm] IFormFile file, - [FromForm] int? replacesDocumentId = null) - { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - if (file == null || file.Length == 0) - return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" }); - if (file.Length > 10 * 1024 * 1024) - return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" }); - - var allowed = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["application/pdf"] = new[] { ".pdf" }, - ["image/jpeg"] = new[] { ".jpg", ".jpeg" }, - ["image/png"] = new[] { ".png" } - }; - var extension = Path.GetExtension(file.FileName); - if (!allowed.TryGetValue(file.ContentType, out var extensions) - || !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) - return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" }); - - await using var signatureStream = file.OpenReadStream(); - if (!await HasExpectedSignature(signatureStream, file.ContentType)) - return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" }); - - var workOrderId = dispatch.WorkOrderId - ?? await _db.DispatchWorkOrders - .Where(link => link.DispatchId == dispatch.Id) - .OrderBy(link => link.Id) - .Select(link => link.WorkOrderId) - .FirstOrDefaultAsync(); - if (workOrderId == 0) - return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" }); - - VendorCompletionDocument? replaced = null; - if (replacesDocumentId.HasValue) - { - replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document => - document.Id == replacesDocumentId.Value - && document.DispatchId == id - && document.VendorId == vendor.Id); - if (replaced == null) - return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" }); - } - - var now = DateTime.UtcNow; - var document = new VendorCompletionDocument - { - VendorId = vendor.Id, - DispatchId = id, - WorkOrderId = workOrderId, - OriginalFileName = Path.GetFileName(file.FileName), - StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}", - ContentType = file.ContentType, - SizeBytes = file.Length, - ScanStatus = "Pending", - ReviewStatus = "Processing", - Version = (replaced?.Version ?? 0) + 1, - ReplacesDocumentId = replaced?.Id, - CreatedDate = now, - createdby = $"vendor:{vendor.Id}" - }; - var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - await using (var target = System.IO.File.Create(path)) - { - await file.CopyToAsync(target); - } + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); try { - _db.VendorCompletionDocuments.Add(document); - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = document.WorkOrderId, - FieldName = "Completion Document", - OldValue = replaced?.OriginalFileName, - NewValue = document.OriginalFileName, - Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced", - CreatedAt = now - }); - await _db.SaveChangesAsync(); + var result = await _portalService.CancelUpliftRequestAsync(session, id, requestId, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - catch + catch (KeyNotFoundException) { - System.IO.File.Delete(path); - throw; + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - - return Ok(new DataResponse + catch (InvalidOperationException ex) { - Status = "Success", - Data = new - { - document.Id, - document.OriginalFileName, - document.SizeBytes, - document.ScanStatus, - document.ReviewStatus, - document.Version, - document.ReplacesDocumentId - } - }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } } - [HttpGet("dispatches/{id:int}/documents/{documentId:int}")] - public async Task DownloadCompletionDocument(int id, int documentId) + [HttpPost("dispatches/{id:int}/completion-documents")] + [RequestSizeLimit(10_000_000)] + public async Task UploadCompletionDocument(int id, [FromForm] IFormFile file, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item => - item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id); - if (document == null) return NotFound(); - if (document.ScanStatus != "Passed") - return StatusCode(StatusCodes.Status423Locked, - new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" }); - var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); - if (!System.IO.File.Exists(path)) return NotFound(); - return PhysicalFile(path, document.ContentType, document.OriginalFileName); - } + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor) - { try { - var dispatchAudit = await _db.WorkOrderAuditLogs - .Where(a => a.WorkOrderId == (dispatch.WorkOrderId ?? 0) && a.Action == "dispatch" && !string.IsNullOrEmpty(a.UserId)) - .OrderByDescending(a => a.CreatedAt) - .FirstOrDefaultAsync(); - if (dispatchAudit == null || string.IsNullOrWhiteSpace(dispatchAudit.UserId)) return; - - var dispatcher = await _db.Users.FindAsync(dispatchAudit.UserId); - if (dispatcher == null || string.IsNullOrWhiteSpace(dispatcher.Email)) return; - - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - var workOrderLink = dispatch.WorkOrderId.HasValue - ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" - : frontendBase; - - var vendorName = System.Net.WebUtility.HtmlEncode(vendor.CompanyName ?? "Vendor"); - var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); - var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); - var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; - - var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendor.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; - var body = $@" -

Vendor Uplift Request

-

Dispatch: {dispatchNum}

-

Vendor: {vendorName}

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

Vendor Reason

-

{reason}

- "; - - await _sendMessage.SendEMail(dispatcher.Email, subject, body); + var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - catch + catch (KeyNotFoundException) { + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The completion document could not be uploaded") }); } } - private static async Task HasExpectedSignature(Stream stream, string contentType) + [HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")] + public async Task DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default) { - var bytes = new byte[8]; - var count = await stream.ReadAsync(bytes); - if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase)) - return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D; - if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)) - return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; - if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase)) - return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); - return false; + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + + var result = await _portalService.DownloadCompletionDocumentAsync(session, id, documentId, cancellationToken); + return result.Outcome switch + { + VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!), + VendorDocumentDownloadOutcome.Locked => StatusCode(StatusCodes.Status423Locked, new Response { Status = "Locked", Message = "The completion document is not available for download yet." }), + _ => NotFound(new Response { Status = "Error", Message = "Completion document not found" }) + }; } - private async Task ResolveVendorAsync() + private async Task ResolveSessionAsync(CancellationToken cancellationToken) { if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; var token = values.ToString(); - return await _tokens.ResolveVendorAsync(token); - } - - private Task LoadVendorDispatchAsync(int id, int vendorId) - { - return _db.Dispatches - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .FirstOrDefaultAsync(d => d.Id == id - && d.VendorId == vendorId - && (d.IsDeleted == null || d.IsDeleted == false)); - } - - private static bool IsAllowedVendorTransition(string? from, string? to) - { - if (from == "Acknowledged" && to == "In Progress") return true; - if (from == "In Progress" && to == "Completed") return true; - return false; + return await _portalService.ResolveSessionAsync(token, cancellationToken); } public class ChangeStatusRequest diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs new file mode 100644 index 0000000..204b742 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -0,0 +1,216 @@ +using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderBoardController : Controller + { + private readonly IWorkOrderBoardService _workOrderBoardService; + private readonly IWorkOrderBoardUpdateService _boardUpdateService; + private readonly IWorkOrderBoardCreateService _boardCreateService; + private readonly IWorkOrderBoardCancelService _boardCancelService; + private readonly IWorkOrderAdvancedSearchService _advancedSearchService; + + public WorkOrderBoardController( + IWorkOrderBoardService workOrderBoardService, + IWorkOrderBoardUpdateService boardUpdateService, + IWorkOrderBoardCreateService boardCreateService, + IWorkOrderBoardCancelService boardCancelService, + IWorkOrderAdvancedSearchService advancedSearchService) + { + _workOrderBoardService = workOrderBoardService; + _boardUpdateService = boardUpdateService; + _boardCreateService = boardCreateService; + _boardCancelService = boardCancelService; + _advancedSearchService = advancedSearchService; + } + + /// + /// Weekly board payload. + /// When is true, the authenticated user filter wins and + /// is ignored (not AND-combined). + /// Weekend WOs in a Mon–Sun window may have dayGroup = null; FE should handle that. + /// + [HttpGet("board")] + public async Task GetBoard( + [FromQuery, BindRequired] DateOnly weekStart, + [FromQuery] DateOnly? weekEnd = null, + [FromQuery] List? dispatchers = null, + [FromQuery] bool myWorkOrders = false, + [FromQuery] List? types = null, + [FromQuery] string? search = null) + { + var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4); + var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd); + if (weekValidationError != null) + return BadRequest(weekValidationError); + + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var query = new WorkOrderBoardQueryDto + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Dispatchers = dispatchers, + MyWorkOrders = myWorkOrders, + Types = types, + Search = search + }; + + var result = await _workOrderBoardService.GetBoardAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("board/search")] + public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _advancedSearchService.SearchAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + /// + /// Dispatcher dropdown options. Currently returns all non-deleted users + /// (role-filtered assignee list deferred until roles settle). + /// + [HttpGet("lookups/dispatchers")] + public async Task GetDispatcherLookups() + { + var result = await _workOrderBoardService.GetDispatcherLookupsAsync(); + return Ok(result); + } + + [HttpPatch("{id:int}/board")] + public async Task PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId); + return Ok(row); + } + catch (WorkOrderBoardConcurrencyException ex) + { + return Conflict(new WorkOrderBoardConflictDto + { + CurrentState = ex.CurrentState + }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpPost("board")] + public async Task CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardCreateService.CreateAsync(request, actorId); + return Ok(row); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber") + { + return Conflict(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + } + + [HttpPost("{id:int}/cancel")] + public async Task CancelBoardWorkOrder(int id) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardCancelService.CancelAsync(id, actorId); + return Ok(row); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs new file mode 100644 index 0000000..e253cbd --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -0,0 +1,139 @@ +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderCompletionController : Controller + { + private readonly IWorkOrderCompletionService _workOrderCompletionService; + private readonly IFileStoragePort _fileStorage; + + public WorkOrderCompletionController( + IWorkOrderCompletionService workOrderCompletionService, + IFileStoragePort fileStorage) + { + _workOrderCompletionService = workOrderCompletionService; + _fileStorage = fileStorage; + } + + [HttpGet("completion-templates")] + public async Task GetCompletionTemplates( + [FromQuery] string? serviceKey = null, + [FromQuery] WorkOrderType? workOrderType = null) + { + var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType); + return Ok(templates); + } + + [HttpGet("completion-templates/{id:int}")] + public async Task GetCompletionTemplate(int id) + { + var template = await _workOrderCompletionService.GetTemplateByIdAsync(id); + if (template == null) + return NotFound(new Response { Status = "Error", Message = "Template not found." }); + return Ok(template); + } + + [Authorize(Roles = "Admin")] + [HttpPost("completion-templates")] + public async Task CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request) + { + try + { + var created = await _workOrderCompletionService.CreateTemplateAsync(request); + return Ok(created); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [Authorize(Roles = "Admin")] + [HttpPut("completion-templates/{id:int}")] + public async Task UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request) + { + try + { + var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request); + return Ok(updated); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [Authorize(Roles = "Admin")] + [HttpDelete("completion-templates/{id:int}")] + public async Task DeleteCompletionTemplate(int id) + { + try + { + await _workOrderCompletionService.DeleteTemplateAsync(id); + return NoContent(); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPost("{id:int}/completion-doc")] + [RequestSizeLimit(30_000_000)] + public async Task UploadCompletionDoc( + int id, + [FromForm] IFormFile file, + [FromForm] string? signOffName = null, + [FromForm] string? signOffSignature = null, + [FromForm] string? workOrderVersion = null) + { + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "file is required." }); + + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var fileUrl = await _fileStorage.SaveFileAsync(file); + var result = await _workOrderCompletionService.UploadCompletionDocAsync( + id, + new WorkOrderCompletionDocUploadDto + { + SignOffName = signOffName, + SignOffSignature = signOffSignature, + WorkOrderVersion = workOrderVersion + }, + fileUrl, + actorId); + return Ok(result); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict") + { + return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9edc8e8..9d99e2e 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -1,26 +1,14 @@ using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; using FluentValidation; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components.Forms; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Data.SqlClient; -using Microsoft.EntityFrameworkCore; -using Microsoft.VisualBasic; -using SendGrid.Helpers.Mail; -using System.IO; -using System.Net.Http.Headers; -using System.Security.Claims; -using static System.Runtime.InteropServices.JavaScript.JSType; -using SeaHaven.Services.Interfaces; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; -using SeaHaven.Services.Exceptions; -using SeaHaven.Services.Helpers; -using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { @@ -30,464 +18,15 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderController : Controller { - private readonly UserManager _userManager; private readonly IWorkOrderService _workOrderService; - private readonly IWorkOrderDataService _workOrderDataService; - private readonly ICommentDataService _commentDataService; - private readonly IQuotesDataService _quotesDataService; - private readonly IUserDataService _userDataService; - private readonly IDispatchDataService _dispatchDataService; - private readonly IVendorDataService _vendorDataService; - private readonly ITaskListTemplateDataService _taskListTemplateDataService; - private readonly ApplicationDbContext _db; - private readonly IWebHostEnvironment _webHostEnvironment; - IHttpContextAccessor _httpContext; - private readonly Helper.SendMessage _sendMessage; - private readonly Helper.VendorPortalTokenService _vendorTokens; - private readonly IConfiguration _config; - private readonly IWorkOrderBoardService _workOrderBoardService; - private readonly IWorkOrderBoardUpdateService _boardUpdateService; - private readonly IWorkOrderBoardCreateService _boardCreateService; - private readonly IWorkOrderBoardCancelService _boardCancelService; - private readonly IWorkOrderAdvancedSearchService _advancedSearchService; - private readonly IWorkOrderDetailService _workOrderDetailService; - private readonly IWorkOrderCommentService _workOrderCommentService; - private readonly IWorkOrderCompletionService _workOrderCompletionService; - private readonly IWorkOrderMediaService _workOrderMediaService; + private readonly ILogger _logger; public WorkOrderController( - UserManager userManager, IWorkOrderService workOrderService, - IWorkOrderDataService workOrderDataService, - ICommentDataService commentDataService, - IQuotesDataService quotesDataService, - IUserDataService userDataService, - IDispatchDataService dispatchDataService, - IVendorDataService vendorDataService, - ITaskListTemplateDataService taskListTemplateDataService, - ApplicationDbContext db, - IWebHostEnvironment webHostEnvironment, - IHttpContextAccessor httpContext, - Helper.SendMessage sendMessage, - Helper.VendorPortalTokenService vendorTokens, - IConfiguration config, - IWorkOrderBoardService workOrderBoardService, - IWorkOrderBoardUpdateService boardUpdateService, - IWorkOrderBoardCreateService boardCreateService, - IWorkOrderBoardCancelService boardCancelService, - IWorkOrderAdvancedSearchService advancedSearchService, - IWorkOrderDetailService workOrderDetailService, - IWorkOrderCommentService workOrderCommentService, - IWorkOrderCompletionService workOrderCompletionService, - IWorkOrderMediaService workOrderMediaService) + ILogger logger) { - _userManager = userManager; _workOrderService = workOrderService; - _workOrderDataService = workOrderDataService; - _commentDataService = commentDataService; - _quotesDataService = quotesDataService; - _userDataService = userDataService; - _dispatchDataService = dispatchDataService; - _vendorDataService = vendorDataService; - _taskListTemplateDataService = taskListTemplateDataService; - _db = db; - _webHostEnvironment = webHostEnvironment; - _httpContext = httpContext; - _sendMessage = sendMessage; - _vendorTokens = vendorTokens; - _config = config; - _workOrderBoardService = workOrderBoardService; - _boardUpdateService = boardUpdateService; - _boardCreateService = boardCreateService; - _boardCancelService = boardCancelService; - _advancedSearchService = advancedSearchService; - _workOrderDetailService = workOrderDetailService; - _workOrderCommentService = workOrderCommentService; - _workOrderCompletionService = workOrderCompletionService; - _workOrderMediaService = workOrderMediaService; - } - - /// - /// Weekly board payload. - /// When is true, the authenticated user filter wins and - /// is ignored (not AND-combined). - /// Weekend WOs in a Mon–Sun window may have dayGroup = null; FE should handle that. - /// - [HttpGet("board")] - public async Task GetBoard( - [FromQuery, BindRequired] DateOnly weekStart, - [FromQuery] DateOnly? weekEnd = null, - [FromQuery] List? dispatchers = null, - [FromQuery] bool myWorkOrders = false, - [FromQuery] List? types = null, - [FromQuery] string? search = null) - { - var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4); - var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd); - if (weekValidationError != null) - return BadRequest(weekValidationError); - - try - { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var query = new WorkOrderBoardQueryDto - { - WeekStart = weekStart, - WeekEnd = weekEnd, - Dispatchers = dispatchers, - MyWorkOrders = myWorkOrders, - Types = types, - Search = search - }; - - var result = await _workOrderBoardService.GetBoardAsync(query, userId); - return Ok(result); - } - catch (ArgumentException ex) - { - return BadRequest(ex.Message); - } - } - - [HttpGet("board/search")] - public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) - { - try - { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var result = await _advancedSearchService.SearchAsync(query, userId); - return Ok(result); - } - catch (ArgumentException ex) - { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); - } - } - - /// - /// Dispatcher dropdown options. Currently returns all non-deleted users - /// (role-filtered assignee list deferred until roles settle). - /// - [HttpGet("lookups/dispatchers")] - public async Task GetDispatcherLookups() - { - var result = await _workOrderBoardService.GetDispatcherLookupsAsync(); - return Ok(result); - } - - [HttpPatch("{id:int}/board")] - public async Task PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request) - { - try - { - var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId); - return Ok(row); - } - catch (WorkOrderBoardConcurrencyException ex) - { - return Conflict(new WorkOrderBoardConflictDto - { - CurrentState = ex.CurrentState - }); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto - { - Code = ex.Code, - Message = ex.Message - }); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto - { - Code = ex.Code, - Message = ex.Message - }); - } - catch (ArgumentException ex) - { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); - } - } - - [HttpPost("board")] - public async Task CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request) - { - try - { - var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var row = await _boardCreateService.CreateAsync(request, actorId); - return Ok(row); - } - catch (ValidationException vex) - { - var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); - return BadRequest(new Response { Status = "Validation Error", Message = errors }); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber") - { - return Conflict(new WorkOrderBoardValidationErrorDto - { - Code = ex.Code, - Message = ex.Message - }); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto - { - Code = ex.Code, - Message = ex.Message - }); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto - { - Code = ex.Code, - Message = ex.Message - }); - } - } - - [HttpPost("{id:int}/cancel")] - public async Task CancelBoardWorkOrder(int id) - { - try - { - var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var row = await _boardCancelService.CancelAsync(id, actorId); - return Ok(row); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto - { - Code = ex.Code, - Message = ex.Message - }); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto - { - Code = ex.Code, - Message = ex.Message - }); - } - } - - [HttpGet("{id:int}/detail")] - public async Task GetDetail(int id) - { - var detail = await _workOrderDetailService.GetDetailAsync(id); - if (detail == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(detail); - } - - [HttpGet("{id:int}/audit")] - public async Task GetAudit(int id, [FromQuery] int limit = 50) - { - var audit = await _workOrderDetailService.GetAuditAsync(id, limit); - if (audit == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(audit); - } - - [HttpGet("{id:int}/comments")] - public async Task GetBoardComments(int id) - { - var comments = await _workOrderCommentService.GetCommentsAsync(id); - if (comments == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(comments); - } - - [HttpPost("{id:int}/comments")] - public async Task AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request) - { - try - { - var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId); - return Ok(comment); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - } - - [HttpGet("completion-templates")] - public async Task GetCompletionTemplates( - [FromQuery] string? serviceKey = null, - [FromQuery] WorkOrderType? workOrderType = null) - { - var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType); - return Ok(templates); - } - - [HttpGet("completion-templates/{id:int}")] - public async Task GetCompletionTemplate(int id) - { - var template = await _workOrderCompletionService.GetTemplateByIdAsync(id); - if (template == null) - return NotFound(new Response { Status = "Error", Message = "Template not found." }); - return Ok(template); - } - - [Authorize(Roles = "Admin")] - [HttpPost("completion-templates")] - public async Task CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request) - { - try - { - var created = await _workOrderCompletionService.CreateTemplateAsync(request); - return Ok(created); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - } - - [Authorize(Roles = "Admin")] - [HttpPut("completion-templates/{id:int}")] - public async Task UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request) - { - try - { - var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request); - return Ok(updated); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - } - - [Authorize(Roles = "Admin")] - [HttpDelete("completion-templates/{id:int}")] - public async Task DeleteCompletionTemplate(int id) - { - try - { - await _workOrderCompletionService.DeleteTemplateAsync(id); - return NoContent(); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - } - - [HttpPost("{id:int}/completion-doc")] - [RequestSizeLimit(30_000_000)] - public async Task UploadCompletionDoc( - int id, - [FromForm] IFormFile file, - [FromForm] string? signOffName = null, - [FromForm] string? signOffSignature = null, - [FromForm] string? workOrderVersion = null) - { - if (file == null || file.Length == 0) - return BadRequest(new Response { Status = "Error", Message = "file is required." }); - - try - { - var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var fileUrl = await SaveFileAsync(file); - var result = await _workOrderCompletionService.UploadCompletionDocAsync( - id, - new WorkOrderCompletionDocUploadDto - { - SignOffName = signOffName, - SignOffSignature = signOffSignature, - WorkOrderVersion = workOrderVersion - }, - fileUrl, - actorId); - return Ok(result); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict") - { - return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - } - - [HttpGet("{id:int}/media")] - public async Task GetMedia(int id) - { - var media = await _workOrderMediaService.GetMediaAsync(id); - if (media == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(media); - } - - [HttpPost("{id:int}/media")] - [RequestSizeLimit(30_000_000)] - public async Task AddMedia( - int id, - [FromForm] WorkOrderMediaCategory category, - [FromForm] IFormFile file) - { - if (file == null || file.Length == 0) - return BadRequest(new Response { Status = "Error", Message = "file is required." }); - - try - { - var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var fileUrl = await SaveFileAsync(file); - var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); - return Ok(media); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - } - - [HttpDelete("{id:int}/media/{mediaId:int}")] - public async Task DeleteMedia(int id, int mediaId) - { - try - { - var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId); - return NoContent(); - } - catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") - { - return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } - catch (WorkOrderBoardValidationException ex) - { - return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); - } + _logger = logger; } [HttpPost] @@ -497,76 +36,23 @@ namespace Api.SeaHavenIndustries.Controllers try { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - var createDto = new CreateWorkOrderDTO + var input = new CreateWorkOrderWithDetailsInput { WorkerOrderTitle = model.WorkerOrderTitle ?? "", Description = model.Description, - Status = WorkOrderStatus.Open, - Priority = model.Priority ?? WorkOrderPriority.Medium, + Priority = model.Priority?.ToString(), LocationId = model.LocationId, AssignTo = model.AssignTo, - DueDate = model.DueDate + DueDate = model.DueDate, + ContactIds = model.ContactIds, + CategoryIds = model.CategoryIds, + Attachments = model.Attachments, + BeforPhotoAttachment = model.BeforPhotoAttachment, + AfterPhotoAttachment = model.AfterPhotoAttachment, + SignOffAttachment = model.SignOffAttachment }; - var workOrderId = await _workOrderService.CreateWorkOrderAsync(createDto, userId!); - - if (model.ContactIds != null && model.ContactIds.Any()) - { - await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, model.ContactIds); - } - - if (model.CategoryIds != null && model.CategoryIds.Any()) - { - await _workOrderDataService.AddWorkOrderCategoriesAsync(workOrderId, model.CategoryIds); - } - - if (model.Attachments != null) - { - foreach (var attachment in model.Attachments) - { - var fileUrl = await SaveFileAsync(attachment); - await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments - { - WorkorderId = workOrderId, - Attachments = fileUrl - }); - } - } - - if (model.BeforPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.BeforPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.BeforPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.AfterPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.AfterPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.AfterPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.SignOffAttachment != null) - { - var fileUrl = await SaveFileAsync(model.SignOffAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.SignOffAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - + var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, userId!); return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId }); } catch (ValidationException vex) @@ -576,11 +62,10 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } - [HttpPost] [HttpPut("{id:int}")] [Route("EditWorkorder")] @@ -592,82 +77,29 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = "Work order ID is required" }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - var updateDto = new UpdateWorkOrderDTO + var input = new UpdateWorkOrderWithDetailsInput { + Id = model.Id.Value, WorkerOrderTitle = model.WorkerOrderTitle, Description = model.Description, - Status = model.Status, - Priority = model.Priority, + Status = model.Status?.ToString(), + Priority = model.Priority?.ToString(), LocationId = model.LocationId, AssignTo = model.AssignTo, DueDate = model.DueDate, - CompletedDate = model.CompletedDate + CompletedDate = model.CompletedDate, + ContactIds = model.ContactIds, + CategoryIds = model.CategoryIds, + Attachments = model.Attachments, + BeforPhotoAttachment = model.BeforPhotoAttachment, + AfterPhotoAttachment = model.AfterPhotoAttachment, + SignOffAttachment = model.SignOffAttachment }; - var success = await _workOrderService.UpdateWorkOrderAsync(model.Id.Value, updateDto, userId!); + var success = await _workOrderService.UpdateWorkOrderWithDetailsAsync(input, userId!); if (!success) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - await _workOrderDataService.DeleteWorkOrderContactsAsync(model.Id.Value); - await _workOrderDataService.DeleteWorkOrderCategoriesAsync(model.Id.Value); - - if (model.ContactIds != null && model.ContactIds.Any()) - { - await _workOrderDataService.AddWorkOrderContactsAsync(model.Id.Value, model.ContactIds); - } - - if (model.CategoryIds != null && model.CategoryIds.Any()) - { - await _workOrderDataService.AddWorkOrderCategoriesAsync(model.Id.Value, model.CategoryIds); - } - - if (model.Attachments != null) - { - foreach (var attachment in model.Attachments) - { - var fileUrl = await SaveFileAsync(attachment); - await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments - { - WorkorderId = model.Id.Value, - Attachments = fileUrl - }); - } - } - - if (model.BeforPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.BeforPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.BeforPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.AfterPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.AfterPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.AfterPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.SignOffAttachment != null) - { - var fileUrl = await SaveFileAsync(model.SignOffAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.SignOffAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (ValidationException vex) @@ -677,24 +109,15 @@ namespace Api.SeaHavenIndustries.Controllers } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } - // ----------------------------------- - // GET: api/WorkOrder/GetWorkOrderList - // ----------------------------------- + [HttpGet("GetWorkOrderList")] public async Task GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) { - var (data, totalCount) = await _workOrderDataService.GetWorkOrderListPagedAsync( - page, - pageSize, - search, - status, - assignee, - locationId, - sortBy, - sortDir); + var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync( + page, pageSize, search, status, assignee, locationId, sortBy, sortDir); var viewModel = new Pagination_DTO { @@ -708,7 +131,6 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(viewModel); } - [Authorize(Roles = "Admin")] [HttpDelete] [Route("DeleteWorkorder")] @@ -716,135 +138,46 @@ namespace Api.SeaHavenIndustries.Controllers { int id = model.Id.HasValue ? model.Id.Value : 0; if (id == 0) - { return BadRequest("Id is required"); - } + try { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var success = await _workOrderService.DeleteWorkOrderAsync(id, userId!); + var success = await _workOrderService.DeleteWorkOrderCascadeAsync(id, userId!); if (!success) return BadRequest("Work order not found!"); - await _quotesDataService.UnlinkFromWorkOrderAsync(id); - - var comments = await _commentDataService.GetByWorkOrderIdAsync(id); - foreach (var comment in comments) - { - await _commentDataService.DeleteAsync(comment.Id); - } - return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } [HttpGet] [Route("GetFilteredWorkorder")] - public IActionResult GetFilteredWorkorder( - [FromQuery] string[] assignto, - [FromQuery] int[]? location, - [FromQuery] string[]? priority, - [FromQuery] string[]? status, - [FromQuery] string[]? duedate, - [FromQuery] string search = "", - [FromQuery] string sort = "Sort Order", - [FromQuery] string sortby = "Sort By", - int page = 1, - int pageSize = 10) + public async Task GetFilteredWorkorder( + [FromQuery] string[] assignto, + [FromQuery] int[]? location, + [FromQuery] string[]? priority, + [FromQuery] string[]? status, + [FromQuery] string[]? duedate, + [FromQuery] string search = "", + [FromQuery] string sort = "Sort Order", + [FromQuery] string sortby = "Sort By", + int page = 1, + int pageSize = 10) { try { - IQueryable query = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations); - - if (!string.IsNullOrEmpty(search)) - { - var s = search.ToLower(); - query = query.Where(w => - (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || - (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || - (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(s))); - } - - if (assignto != null && assignto.Length > 0) - { - if (assignto[0] != "" && assignto[0] != null) - query = query.Where(w => assignto.Contains(w.AssignTo)); - } - - if (location != null && location.Length > 0) - { - if (location[0] != 0) - query = query.Where(w => location.Contains(w.LocationId ?? 0)); - } - - if (priority != null && priority.Length > 0) - { - if (priority[0] != "" && priority[0] != null) - query = query.Where(w => priority.Contains(w.Priority)); - } - - if (status != null && status.Length > 0) - { - if (status[0] != "" && status[0] != null) - query = query.Where(w => status.Contains(w.Status)); - } - var data = query.ToList(); - - // Perform client-side filtering for due dates - data = data.Where(w => - duedate == null || duedate.Length == 0 || duedate[0] == null || - duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, DateTime.UtcNow.Date))).ToList(); - - // Apply sorting - if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") - { - switch (sortby) - { - case "Name": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.WorkerOrderTitle).ToList() : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); - break; - case "Due Date": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.DueDate).ToList() : data.OrderByDescending(w => w.DueDate).ToList(); - break; - case "Severity": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.Priority).ToList() : data.OrderByDescending(w => w.Priority).ToList(); - break; - default: - // Handle default case or throw an exception - break; - } - } - - var totalCount = data.Count(); - var pagedData = data - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(s => new { s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, Name = s.Locations != null ? s.Locations.Name : "" }) - .OrderByDescending(d => d.Name) - .ToList(); - - var totalPages1 = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = pagedData, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages1 - }; - - return Ok(viewModel); + var result = await _workOrderService.GetFilteredWorkOrdersAsync( + assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); + return Ok(result); } - catch (Exception ex) + catch (Exception) { - // Handle exception and return a default result or an error response return StatusCode(500, "Internal Server Error"); } } @@ -853,203 +186,44 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetWorkorderById")] public async Task GetWorkorderById(int id) { - var exist = await _db.workOrders.Include(w => w.Locations).Include(w => w.Comments).Include(w => w.Quotes).Include(w => w.Dispatches).ThenInclude(d => d.Vendor).Where(w => w.Id == id).Select(s => new - { - s.Id, - s.InternalWONumber, - s.WorkerOrderNumber, - title = s.WorkerOrderTitle, - s.Description, - s.CreatedDate, - s.DueDate, - s.ScheduledDate, - s.CompletedDate, - s.Status, - assignee = s.AssignToUser != null ? s.AssignToUser.FirstName : "", - location = s.Locations != null ? s.Locations.Name : "", - locationId = s.LocationId, - locationZip = s.Locations != null ? (s.Locations.Zip ?? ExtractZip(s.Locations.Address1)) : "", - priority = s.Priority, - s.Problem, - s.Trade, - s.SubTrade, - s.VendorNTE, - s.Source, - s.PO, - s.TT, - poc = s.WorkOrderContacts.Select(c => new { c.Id, name = c.POC != null ? (c.POC.FirstName + " " + c.POC.LastName).Trim() : "", email = c.POC != null ? c.POC.Email : "", phone = c.POC != null ? c.POC.PhoneNumber : "" }), - category = s.WorkOrderCategories.Select(c => new { c.Id, name = c.Category != null ? c.Category.Name : "" }), - Attachments = s.workOrderAttachments.Select(a => new { a.Id, attachment = a.Attachments }), - s.BeforPhotoIssue, - s.BeforPhotoAttachment, - s.AfterPhotoIssue, - s.AfterPhotoAttachment, - s.SignOffName, - s.SignOffAttachment, - s.SignOffSignature, - Comments = s.Comments.Select(c => new { c.Id, c.CreatedDate, c.Commenttext, c.Commenter, c.RecordType, c.CommentType, c.Documents, userName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : "" }), - quotes = s.Quotes.Select(q => new { code = q.QuotId, q.Id }), - auditLog = _db.WorkOrderAuditLogs - .Where(a => a.WorkOrderId == s.Id) - .OrderByDescending(a => a.CreatedAt) - .Select(a => new { - a.Id, - a.FieldName, - a.OldValue, - a.NewValue, - a.Action, - a.CreatedAt, - userName = a.User != null ? a.User.FirstName + " " + a.User.LastName : "" - }).ToList(), - dispatches = s.Dispatches.Select(d => new { - d.Id, - d.DispatchNumber, - d.PONumber, - d.NTEAmount, - d.Description, - d.Status, - d.ScheduledDate, - d.EstimatedArrivalAt, - d.EtaManualOverride, - d.CompletedDate, - d.DispatchedAt, - d.EmailSent, - vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", - vendorEmail = d.Vendor != null ? d.Vendor.Email : "", - vendorId = d.VendorId - }).ToList() - }).FirstOrDefaultAsync(); - if (exist == null) - { - + var detail = await _workOrderService.GetWorkOrderDetailAsync(id); + if (detail == null) return BadRequest(new Response { Status = "Error", Message = "ID not found!" }); - } - else - { - return Ok(exist); - } + return Ok(detail); } + [HttpGet] [Route("GetFilteredWorkorder2")] - public IActionResult GetFilteredWorkorder2([FromQuery] string[] assignto, [FromQuery] int[]? location, [FromQuery] string[]? priority, [FromQuery] string[]? status, [FromQuery] string[]? duedate, [FromQuery] string search = "", [FromQuery] string sort = "Sort Order", [FromQuery] string sortby = "Sort By", int page = 1, int pageSize = 10) + public async Task GetFilteredWorkorder2( + [FromQuery] string[] assignto, + [FromQuery] int[]? location, + [FromQuery] string[]? priority, + [FromQuery] string[]? status, + [FromQuery] string[]? duedate, + [FromQuery] string search = "", + [FromQuery] string sort = "Sort Order", + [FromQuery] string sortby = "Sort By", + int page = 1, + int pageSize = 10) { try { - var totalCount = _db.workOrders.Where(w => w.istemplate != true).Count(); - if (sort == "Sort Order" && sortby == "Sort By" && assignto.Count() == 0 && (duedate == null || duedate.Count() == 0) && location.Count() == 0 && (priority == null || priority.Count() == 0) && (status == null || status.Count() == 0) && search == "") + var result = await _workOrderService.GetFilteredWorkOrders2Async( + assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); + return Ok(result); + } + catch (Exception) + { + var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync(); + return Ok(new Pagination_DTO { - var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).ToListAsync(); - return Ok(data1); - } - //if (sort == "Sort Order" && sortby == "Sort By" && assignto.Count() == 0 && (duedate == null || duedate.Count() == 0) && location.Count() == 0 && (priority == null || priority.Count() == 0) && (status == null || status.Count() == 0) && search == "") - //{ - // var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).Select(s => new {s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, s.Locations.Name }).OrderByDescending(d => d.Name).ToList().Skip((page - 1) * pageSize) - // .Take(pageSize); - - // // Calculate the number of pages - // var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - // // Create a view model containing the paged items and pagination metadata - // var viewModel1 = new Pagination_DTO - // { - // Data = data1, - // PageNumber = page, - // PageSize = pageSize, - // TotalCount = totalCount, - // TotalPages = totalPages - // }; - // return Ok(viewModel1); - //} - - var currentDate = DateTime.UtcNow.Date; - var data = _db.workOrders.Include(w => w.Locations) - .Where(w => w.istemplate != true && (((w.WorkerOrderTitle ?? "").ToLower().Contains(search.ToLower()) || (w.WorkerOrderNumber ?? "").ToLower().Contains(search.ToLower()) || (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(search.ToLower()))) && - (assignto.Count() == 0 || assignto.Contains(w.AssignTo)) && - (location.Count() == 0 || (w.LocationId.HasValue && location.Contains(w.LocationId.Value))) && - (priority.Count() == 0 || priority.Contains(w.Priority)) && - ((status.Count() == 0 || status.Contains(w.Status)) - ))) - .ToList(); - - // Perform client-side filtering for due dates - data = data.Where(w => - duedate.Count() == 0 || - duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, currentDate))).ToList(); - - // Apply sorting - if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") - { - switch (sortby) - { - case "Name": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.WorkerOrderTitle).ToList() : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); - break; - case "Due Date": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.DueDate).ToList() : data.OrderByDescending(w => w.DueDate).ToList(); - break; - case "Severity": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.Priority).ToList() : data.OrderByDescending(w => w.Priority).ToList(); - break; - default: - // Handle default case or throw an exception - break; - } - } - var newdata = data.Select(s => new { s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, Name = s.Locations != null ? s.Locations.Name : "" }).OrderByDescending(d => d.Name).ToList().Skip((page - 1) * pageSize) - .Take(pageSize); - var totalPages1 = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = newdata, + Data = Array.Empty(), PageNumber = page, PageSize = pageSize, TotalCount = totalCount, - TotalPages = totalPages1 - }; - return Ok(viewModel); + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }); } - catch (Exception ex) - { - var totalCount = _db.workOrders.Where(w => w.istemplate != true).Count(); - var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).Select(s => new - { - s.Id, - s.WorkerOrderTitle, - s.DueDate, - s.Status, - s.Priority, - Name = s.Locations != null ? s.Locations.Name : "" - }).OrderByDescending(d => d.Id).ToList().Skip((page - 1) * pageSize) - .Take(pageSize); - - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = data1, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages - }; - return Ok(viewModel); - } - } - bool CheckDueDate(string dd, DateTime dueDate, DateTime currentDate) - { - return dd switch - { - "Today" => dueDate == currentDate, - "Tomorrow" => dueDate == currentDate.AddDays(1), - "Next Week" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddDays(7), - "Next Month" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddMonths(1), - "Over Due" => dueDate < currentDate, - _ => false - }; } [HttpPost] @@ -1058,34 +232,16 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var workOrder = await _workOrderDataService.GetByIdAsync(id); - if (workOrder == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _workOrderService.ChangeStatusAsync(id, status, userId!); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (workOrder.Status != status) - { - await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = id, - UserId = userId, - FieldName = "Status", - OldValue = workOrder.Status ?? "", - NewValue = status, - Action = AuditAction.StatusChange.ToString(), - CreatedAt = DateTime.UtcNow - }); - } - - workOrder.Status = status; - await _workOrderDataService.UpdateAsync(workOrder); - - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = WorkOrderDTO.MapToModel(workOrder) }); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = result }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -1095,39 +251,16 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var workOrder = await _workOrderDataService.GetByIdAsync(id); - if (workOrder == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _workOrderService.ChangeAssignmentAsync(id, assignTo, userId!); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (workOrder.AssignTo != assignTo) - { - var oldUser = workOrder.AssignTo != null ? await _userDataService.GetByIdAsync(workOrder.AssignTo) : null; - var newUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; - - await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = id, - UserId = userId, - FieldName = "AssignedTo", - OldValue = oldUser != null ? (oldUser.FirstName + " " + oldUser.LastName).Trim() : "", - NewValue = newUser != null ? (newUser.FirstName + " " + newUser.LastName).Trim() : "Unassigned", - Action = AuditAction.AssignmentChange.ToString(), - CreatedAt = DateTime.UtcNow - }); - } - - workOrder.AssignTo = assignTo; - await _workOrderDataService.UpdateAsync(workOrder); - - var assignedUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; - var assignedName = assignedUser != null ? (assignedUser.FirstName + " " + assignedUser.LastName).Trim() : ""; - return Ok(new { message = "Updated Successfully", assignedTo = assignedName }); + return Ok(result); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -1137,28 +270,21 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - - var comment = new Comments + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddCommentInput { - UserId = userId, - WorkerOrderId = model.WorkorderId, - Commenttext = model.Text, - CommentType = model.CommentType ?? "vendor" + WorkorderId = model.WorkorderId, + Text = model.Text, + CommentType = model.CommentType, + Document = model.Document }; - if (model.Document != null) - { - var fileUrl = await SaveFileAsync(model.Document); - comment.Documents = fileUrl; - } - - var saved = await _commentDataService.AddAsync(comment); + var saved = await _workOrderService.AddCommentAsync(input, userId!); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = saved }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -1168,30 +294,20 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - var comment = new Comments + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddCommentInput { - UserId = userId, - WorkerOrderId = model.WorkorderId, - Commenttext = model.Text, - CommentType = model.CommentType ?? "internal", + WorkorderId = model.WorkorderId, + Text = model.Text, + CommentType = model.CommentType }; - var saved = await _commentDataService.AddAsync(comment); - var user = await _userDataService.GetByIdAsync(userId); - - return Ok(new - { - saved.Id, - saved.CreatedDate, - saved.Commenttext, - saved.CommentType, - userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" - }); + var result = await _workOrderService.AddCommentJsonAsync(input, userId!); + return Ok(result); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); } } @@ -1199,14 +315,7 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetComments")] public async Task GetComments() { - var comments = await _commentDataService.GetAllAsync(); - var data = comments.Select(s => new - { - s.Id, - s.Commenttext, - FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", - s.Documents - }).ToList(); + var data = await _workOrderService.GetCommentsAsync(); return Ok(data); } @@ -1214,589 +323,24 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetCommentsByWorkorderId")] public async Task GetCommentsByWorkorderId(int woid) { - var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); - var data = comments.Select(s => new - { - s.Id, - s.Commenttext, - FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", - s.Documents - }).ToList(); + var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid); return Ok(data); } + [HttpGet] [Route("GetworkordersDD")] public async Task GetworkordersDD() { - var data = await _workOrderDataService.GetAllAsync(); - return Ok(data.Where(w => w.istemplate != true)); + var data = await _workOrderService.GetWorkordersDDAsync(); + return Ok(data); } + [HttpGet] [Route("Getworkorders")] public async Task Getworkorders() { - var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); - var data = workOrders.Select(s => new WorkorderFilterVM - { - Id = s.Id, - Priority = s.Priority, - Status = s.Status, - WorkerOrderNumber = s.WorkerOrderNumber, - WorkerOrderTitle = s.WorkerOrderTitle, - DueDate = s.DueDate, - AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "", - LocationName = s.Locations != null ? s.Locations.Name : "", - LocationId = s.LocationId, - PO = s.PO, - TT = s.TT, - }).ToList(); + var data = await _workOrderService.GetWorkordersAsync(); return Ok(data); } - [HttpPost] - [Route("DispatchToVendor")] - public async Task DispatchToVendor([FromBody] Dispatch_DTO model) - { - try - { - var allWorkOrders = await _workOrderDataService.GetAllWithDetailsAsync(); - var workOrders = allWorkOrders.Where(w => model.WorkOrderIds.Contains(w.Id)).ToList(); - - if (workOrders.Count == 0) - return BadRequest(new Response { Status = "Error", Message = "No work orders found" }); - - var primaryWO = workOrders.First(); - - var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); - int nextPO = int.TryParse(lastPO, out var po) ? po : 1; - - var lastDSP = await _dispatchDataService.GenerateNextDispatchNumberAsync(); - int nextDSP = lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var dspNum) ? dspNum : 1; - - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var currentUser = userId != null ? await _userDataService.GetByIdAsync(userId) : null; - var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : ""; - var dispatches = new List(); - - foreach (var vendorId in model.VendorIds) - { - var vendor = await _vendorDataService.GetByIdAsync(vendorId); - if (vendor == null) - return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} was not found" }); - if (!vendor.IsActive || (vendor.IsDeleted ?? false)) - return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} is inactive and cannot receive a new assignment" }); - - var poNumber = $"VPO-{nextPO:D5}"; - nextPO++; - var dispatchNumber = $"DSP-{nextDSP:D5}"; - nextDSP++; - - var replyTo = $"wo-{primaryWO.InternalWONumber}-{dispatchNumber}@int.seahaven.com"; - - var dispatch = new Dispatch - { - WorkOrderId = primaryWO.Id, - VendorId = vendorId, - DispatchNumber = dispatchNumber, - PONumber = poNumber, - NTEAmount = model.NTEAmount, - Description = model.Description, - Status = "Sent", - ScheduledDate = model.ScheduledDate, - EstimatedArrivalAt = model.EstimatedArrivalAt, - EtaManualOverride = model.EtaManualOverride, - DispatchedAt = DateTime.UtcNow, - ReplyToAddress = replyTo, - DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() - }; - - var checklistItems = new List(); - if (model.TaskListTemplateId.HasValue) - { - var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(model.TaskListTemplateId.Value); - if (template?.Items != null) - { - checklistItems.AddRange(template.Items.OrderBy(i => i.SortOrder).Select((i, idx) => - new DispatchChecklistItem { ItemText = i.ItemText, SortOrder = idx })); - } - } - if (model.CustomChecklistItems != null) - { - var startIdx = checklistItems.Count; - checklistItems.AddRange(model.CustomChecklistItems.Select((text, idx) => - new DispatchChecklistItem { ItemText = text, SortOrder = startIdx + idx })); - } - dispatch.ChecklistItems = checklistItems; - - var woListHtml = string.Join("", workOrders.Select(wo => - $"{System.Net.WebUtility.HtmlEncode(wo.InternalWONumber ?? "")}" + - $"{System.Net.WebUtility.HtmlEncode(wo.WorkerOrderTitle ?? "")}" + - $"{System.Net.WebUtility.HtmlEncode(wo.Priority ?? "")}" + - $"{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}")); - - var locationName = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Name ?? ""); - var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address1 ?? ""); - var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {primaryWO.Locations?.Name ?? ""}"; - var descriptionHtml = System.Net.WebUtility.HtmlEncode(model.Description ?? primaryWO.Description ?? ""); - var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); - - var vendorToken = await _vendorTokens.GetOrCreateActiveTokenAsync(vendorId); - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - var portalUrl = $"{frontendBase}/v/{vendorToken.Token}/dashboard"; - - var html = $@" -

Work Order Dispatch — {dispatchNumber}

-

Location: {locationName} {locationAddr}

-

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

-

Work Orders

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

Description

-

{descriptionHtml}

-
- -

{senderNameHtml}
Sea Haven Industries

-

Reply to this email to communicate about this dispatch.

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

{model.Text}


{senderName}
Sea Haven Industries


Reply to this email to respond.

"; - await _sendMessage.SendDispatchEmail(dispatch.Vendor.Email, subject, html, dispatch.ReplyToAddress); - } - return Ok(new - { - comment.Id, - comment.CreatedDate, - comment.Commenttext, - comment.CommentType, - userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" - }); - } - catch (Exception ex) - { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); - } - } - - private static string? ExtractZip(string? address) - { - if (string.IsNullOrWhiteSpace(address)) return null; - var match = System.Text.RegularExpressions.Regex.Match(address, @"\b(\d{5})(?:-\d{4})?\b"); - return match.Success ? match.Groups[1].Value : null; - } - - [HttpPost("VerifyDispatch")] - public async Task VerifyDispatch(int dispatchId) - { - try - { - var dispatch = await _db.Dispatches.FindAsync(dispatchId); - if (dispatch == null) - return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - - var incompleteItems = await _db.DispatchChecklistItems - .Where(c => c.DispatchId == dispatchId && !c.IsCompleted) - .CountAsync(); - - var signoffCount = await _db.DispatchSignoffs - .Where(s => s.DispatchId == dispatchId) - .CountAsync(); - - var missing = new List(); - if (incompleteItems > 0) - missing.Add($"{incompleteItems} checklist item(s) incomplete"); - if (signoffCount < 2) - { - var existingTypes = await _db.DispatchSignoffs - .Where(s => s.DispatchId == dispatchId) - .Select(s => s.SignoffType) - .ToListAsync(); - if (!existingTypes.Contains("Customer")) missing.Add("Missing Customer sign-off"); - if (!existingTypes.Contains("Vendor")) missing.Add("Missing Vendor sign-off"); - } - - if (missing.Count > 0) - return BadRequest(new { status = "Error", message = "Cannot verify", missing }); - - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var user = userId != null ? await _db.Users.FindAsync(userId) : null; - var userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; - - dispatch.Status = "Verified"; - dispatch.VerifiedBy = userName; - dispatch.VerifiedAt = DateTime.UtcNow; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch.DispatchNumber}", - OldValue = dispatch.Status ?? "", - NewValue = "Verified", - Action = "dispatch_verified", - CreatedAt = DateTime.UtcNow - }); - - await _db.SaveChangesAsync(); - return Ok(new { message = "Dispatch verified", verifiedBy = userName, verifiedAt = dispatch.VerifiedAt }); - } - catch (Exception ex) - { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); - } - } - - [HttpPost("AddDispatchSignoff")] - public async Task AddDispatchSignoff([FromBody] DispatchSignoff_DTO model) - { - try - { - var dispatch = await _db.Dispatches.FindAsync(model.DispatchId); - if (dispatch == null) - return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - - var existing = await _db.DispatchSignoffs - .AnyAsync(s => s.DispatchId == model.DispatchId && s.SignoffType == model.SignoffType); - if (existing) - return BadRequest(new Response { Status = "Error", Message = $"{model.SignoffType} sign-off already exists" }); - - var signoff = new DispatchSignoff - { - DispatchId = model.DispatchId, - SignoffType = model.SignoffType, - Name = model.Name, - Signature = model.Signature, - SignatureMethod = model.SignatureMethod, - SignedAt = DateTime.UtcNow - }; - _db.DispatchSignoffs.Add(signoff); - await _db.SaveChangesAsync(); - - return Ok(new { signoff.Id, signoff.SignoffType, signoff.Name, signoff.SignedAt, signoff.SignatureMethod }); - } - catch (Exception ex) - { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); - } - } - - [HttpPost("UpdateChecklistItem")] - public async Task UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model) - { - var item = await _dispatchDataService.GetChecklistItemByIdAsync(model.Id); - if (item == null) - return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); - - item.IsCompleted = model.IsCompleted; - item.CompletedBy = model.IsCompleted ? model.CompletedBy : null; - item.CompletedAt = model.IsCompleted ? DateTime.UtcNow : null; - await _dispatchDataService.UpdateChecklistItemAsync(item); - return Ok(new { message = "Updated", item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }); - } - - [HttpPost("AddChecklistItem")] - public async Task AddChecklistItem([FromBody] AddChecklistItem_DTO model) - { - var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(model.DispatchId); - var maxSort = checklistItems.Any() ? checklistItems.Max(c => c.SortOrder) : -1; - - var item = new DispatchChecklistItem - { - DispatchId = model.DispatchId, - WorkOrderId = model.WorkOrderId, - ItemText = model.ItemText, - SortOrder = maxSort + 1, - IsCompleted = false - }; - await _dispatchDataService.AddChecklistItemAsync(item); - return Ok(new { item.Id, item.ItemText, item.SortOrder, item.IsCompleted }); - } - - private async Task GenerateInternalWONumber() - { - return await _workOrderDataService.GenerateNextInternalWONumberAsync(); - } - - private async Task SaveFileAsync(IFormFile file) - { - var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"'); - var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - var uploadPath = Path.Combine("Assets", "Documents"); - var fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadPath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - - using (var stream = System.IO.File.Create(fullPath)) - { - await file.CopyToAsync(stream); - } - - var request = _httpContext.HttpContext!.Request; - var domain = $"{request.Scheme}://{request.Host}"; - return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; - } - - public class WorkorderFilterVM - { - public int? Id { get; set; } - public int? LocationId { get; set; } - public string? WorkerOrderNumber { get; set; } - public string? BillingAddress { get; set; } = "Amazon.com Services LLC 410 Terry Ave,North Seattle, Washington, 98109"; - - public string? WorkerOrderTitle { get; set; } - public string? LocationName { get; set; } - public string? AssignToUserName { get; set; } - public string? Priority { get; set; } - public string? Status { get; set; } - public DateTime? DueDate { get; set; } - public string FormattedDueDate - { - get { return DueDate.HasValue ? DueDate.Value.ToString("MM/dd/yyyy") : ""; } - } - public string? Dispatcher { get; set; } - public string? Scheduler { get; set; } - public string? PO { get; set; } - public string? TT { get; set; } - } } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs new file mode 100644 index 0000000..7e3b657 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -0,0 +1,75 @@ +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderDetailController : Controller + { + private readonly IWorkOrderDetailService _workOrderDetailService; + private readonly IWorkOrderCommentService _workOrderCommentService; + + public WorkOrderDetailController( + IWorkOrderDetailService workOrderDetailService, + IWorkOrderCommentService workOrderCommentService) + { + _workOrderDetailService = workOrderDetailService; + _workOrderCommentService = workOrderCommentService; + } + + [HttpGet("{id:int}/detail")] + public async Task GetDetail(int id) + { + var detail = await _workOrderDetailService.GetDetailAsync(id); + if (detail == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(detail); + } + + [HttpGet("{id:int}/audit")] + public async Task GetAudit(int id, [FromQuery] int limit = 50) + { + var audit = await _workOrderDetailService.GetAuditAsync(id, limit); + if (audit == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(audit); + } + + [HttpGet("{id:int}/comments")] + public async Task GetBoardComments(int id) + { + var comments = await _workOrderCommentService.GetCommentsAsync(id); + if (comments == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(comments); + } + + [HttpPost("{id:int}/comments")] + public async Task AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId); + return Ok(comment); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDispatchController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDispatchController.cs new file mode 100644 index 0000000..53186b8 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDispatchController.cs @@ -0,0 +1,214 @@ +using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Helper; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderDispatchController : Controller + { + private readonly IWorkOrderDispatchService _dispatchService; + private readonly ILogger _logger; + + public WorkOrderDispatchController( + IWorkOrderDispatchService dispatchService, + ILogger logger) + { + _dispatchService = dispatchService; + _logger = logger; + } + + [HttpPost] + [Route("DispatchToVendor")] + public async Task DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + + var input = new DispatchToVendorInput + { + WorkOrderIds = model.WorkOrderIds, + VendorIds = model.VendorIds, + NTEAmount = model.NTEAmount, + Description = model.Description, + ScheduledDate = model.ScheduledDate, + TaskListTemplateId = model.TaskListTemplateId, + CustomChecklistItems = model.CustomChecklistItems, + UserId = userId + }; + + var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken); + return Ok(result); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpGet("GetDispatches/{workOrderId}")] + public async Task GetDispatches(int workOrderId) + { + var data = await _dispatchService.GetDispatchesAsync(workOrderId); + return Ok(data); + } + + [HttpGet("GetDispatch/{id}")] + public async Task GetDispatchById(int id) + { + var d = await _dispatchService.GetDispatchDetailAsync(id); + if (d == null) + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + return Ok(d); + } + + [HttpPost("UpdateDispatch")] + public async Task UpdateDispatch([FromBody] UpdateDispatch_DTO model) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new UpdateDispatchInput + { + Id = model.Id, + Status = model.Status, + NTEAmount = model.NTEAmount, + ScheduledDate = model.ScheduledDate, + CompletedDate = model.CompletedDate, + Description = model.Description, + UserId = userId + }; + + var success = await _dispatchService.UpdateDispatchAsync(input); + if (!success) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(new { message = "Dispatch updated" }); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost("AddDispatchComment")] + public async Task AddDispatchComment([FromBody] DispatchComment_DTO model) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddDispatchCommentInput + { + DispatchId = model.DispatchId, + Text = model.Text, + SendEmail = model.SendEmail, + UserId = userId + }; + + var result = await _dispatchService.AddDispatchCommentAsync(input); + if (result == null) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost("VerifyDispatch")] + public async Task VerifyDispatch(int dispatchId) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!); + if (result == null) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(result); + } + catch (DispatchVerificationException ex) + { + return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing }); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost("AddDispatchSignoff")] + public async Task AddDispatchSignoff([FromBody] DispatchSignoff_DTO model) + { + try + { + var input = new AddDispatchSignoffInput + { + DispatchId = model.DispatchId, + SignoffType = model.SignoffType, + Name = model.Name, + Signature = model.Signature, + SignatureMethod = model.SignatureMethod + }; + + var result = await _dispatchService.AddDispatchSignoffAsync(input); + if (result == null) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The dispatch signoff could not be added") }); + } + catch (Exception ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); + } + } + + [HttpPost("UpdateChecklistItem")] + public async Task UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model) + { + var input = new ChecklistItemUpdateInput + { + Id = model.Id, + IsCompleted = model.IsCompleted, + CompletedBy = model.CompletedBy + }; + + var result = await _dispatchService.UpdateChecklistItemAsync(input); + if (result == null) + return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); + + return Ok(result); + } + + [HttpPost("AddChecklistItem")] + public async Task AddChecklistItem([FromBody] AddChecklistItem_DTO model) + { + var input = new AddChecklistItemInput + { + DispatchId = model.DispatchId, + WorkOrderId = model.WorkOrderId, + ItemText = model.ItemText + }; + + var result = await _dispatchService.AddChecklistItemAsync(input); + return Ok(result); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs new file mode 100644 index 0000000..2664d65 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -0,0 +1,85 @@ +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/WorkOrder")] + [Route("api/workorders")] + public class WorkOrderMediaController : Controller + { + private readonly IWorkOrderMediaService _workOrderMediaService; + private readonly IFileStoragePort _fileStorage; + + public WorkOrderMediaController( + IWorkOrderMediaService workOrderMediaService, + IFileStoragePort fileStorage) + { + _workOrderMediaService = workOrderMediaService; + _fileStorage = fileStorage; + } + + [HttpGet("{id:int}/media")] + public async Task GetMedia(int id) + { + var media = await _workOrderMediaService.GetMediaAsync(id); + if (media == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(media); + } + + [HttpPost("{id:int}/media")] + [RequestSizeLimit(30_000_000)] + public async Task AddMedia( + int id, + [FromForm] WorkOrderMediaCategory category, + [FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "file is required." }); + + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var fileUrl = await _fileStorage.SaveFileAsync(file); + var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); + return Ok(media); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpDelete("{id:int}/media/{mediaId:int}")] + public async Task DeleteMedia(int id, int mediaId) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId); + return NoContent(); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + } +} diff --git a/Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs b/Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs new file mode 100644 index 0000000..59dd4c6 --- /dev/null +++ b/Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs @@ -0,0 +1,12 @@ +namespace Api.SeaHavenIndustries.DTOs +{ + public class DropdownOption_DTO + { + public int Id { get; set; } + public string Category { get; set; } = ""; + public string Value { get; set; } = ""; + public string? ParentValue { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + } +} diff --git a/Api.SeaHavenIndustries/Helper/SanitizedErrors.cs b/Api.SeaHavenIndustries/Helper/SanitizedErrors.cs new file mode 100644 index 0000000..1393770 --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/SanitizedErrors.cs @@ -0,0 +1,27 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace Api.SeaHavenIndustries.Helper; + +internal static class SanitizedErrors +{ + private const string DefaultPublicMessage = "An unexpected error occurred"; + + public static string Sanitize( + this ILogger logger, + Exception exception, + string? publicMessage = null, + [CallerMemberName] string operation = "") + { + var correlationId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + + logger.LogError( + exception, + "Unhandled exception in {Operation}. CorrelationId: {CorrelationId}", + operation, + correlationId); + + return $"{publicMessage ?? DefaultPublicMessage} (reference {correlationId})."; + } +} diff --git a/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs b/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs new file mode 100644 index 0000000..b59bb14 --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs @@ -0,0 +1,20 @@ +using Api.SeaHavenIndustries.Helper; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Helper +{ + public class SendGridEmailSender : IEmailSender + { + private readonly SendMessage _sendMessage; + + public SendGridEmailSender(SendMessage sendMessage) + { + _sendMessage = sendMessage; + } + + public Task SendEmailAsync(string emailTo, string subject, string body) + { + return _sendMessage.SendEMail(emailTo, subject, body); + } + } +} diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs index fc5bf0d..08db640 100644 --- a/Api.SeaHavenIndustries/Helper/SendMessage.cs +++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs @@ -1,11 +1,12 @@  +using SeaHaven.Services.Interfaces; using SendGrid; using SendGrid.Helpers.Mail; namespace Api.SeaHavenIndustries.Helper { - public class SendMessage + public class SendMessage : IEmailSender { private IConfiguration _configuration; //string keysapi = ""; @@ -58,6 +59,8 @@ namespace Api.SeaHavenIndustries.Helper return false; } } + public async Task SendEmailAsync(string emailTo, string subject, string htmlBody) + => await SendEMail(emailTo, subject, htmlBody); public async Task SendEMailAttachment(string emailTo, string subject, byte[] pdfBytes) { try diff --git a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs index 0f4adcd..c77e5b6 100644 --- a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs +++ b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs @@ -69,7 +69,10 @@ namespace Api.SeaHavenIndustries.Helper public static string Root(string contentRoot) => Path.Combine(contentRoot, "App_Data", "vendor-documents"); + public static string ResolvePath(string contentRoot, int vendorId, int dispatchId, string storedFileName) => + Path.Combine(Root(contentRoot), vendorId.ToString(), dispatchId.ToString(), storedFileName); + public static string ResolvePath(string contentRoot, VendorCompletionDocument document) => - Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName); + ResolvePath(contentRoot, document.VendorId, document.DispatchId, document.StoredFileName); } } diff --git a/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs b/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs deleted file mode 100644 index 1b9ddee..0000000 --- a/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Security.Cryptography; -using Data.SeaHavenIndustries; -using Microsoft.EntityFrameworkCore; - -namespace Api.SeaHavenIndustries.Helper -{ - public class VendorPortalTokenService - { - private readonly ApplicationDbContext _db; - private readonly int _lifetimeDays; - - public VendorPortalTokenService(ApplicationDbContext db, IConfiguration config) - { - _db = db; - _lifetimeDays = config.GetValue("VendorPortal:TokenLifetimeDays") ?? 365; - } - - public async Task GetOrCreateActiveTokenAsync(int vendorId) - { - var now = DateTime.UtcNow; - var existing = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId - && t.RevokedAt == null - && t.ExpiresAt > now - && (t.IsDeleted == null || t.IsDeleted == false)) - .OrderByDescending(t => t.IssuedAt) - .FirstOrDefaultAsync(); - - if (existing != null) return existing; - - var token = new VendorAccessToken - { - VendorId = vendorId, - Token = GenerateToken(), - IssuedAt = now, - ExpiresAt = now.AddDays(_lifetimeDays), - CreatedDate = now - }; - _db.VendorAccessTokens.Add(token); - await _db.SaveChangesAsync(); - return token; - } - - public async Task ResolveVendorAsync(string? token) - { - if (string.IsNullOrWhiteSpace(token)) return null; - var now = DateTime.UtcNow; - var record = await _db.VendorAccessTokens - .Include(t => t.Vendor) - .FirstOrDefaultAsync(t => t.Token == token - && t.RevokedAt == null - && t.ExpiresAt > now - && (t.IsDeleted == null || t.IsDeleted == false)); - - if (record?.Vendor == null) return null; - - record.LastUsedAt = now; - await _db.SaveChangesAsync(); - return record.Vendor; - } - - public async Task RotateAsync(int vendorId) - { - var now = DateTime.UtcNow; - var active = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId && t.RevokedAt == null) - .ToListAsync(); - foreach (var t in active) t.RevokedAt = now; - await _db.SaveChangesAsync(); - return await GetOrCreateActiveTokenAsync(vendorId); - } - - public async Task RevokeAllAsync(int vendorId) - { - var now = DateTime.UtcNow; - var active = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId && t.RevokedAt == null) - .ToListAsync(); - foreach (var t in active) t.RevokedAt = now; - await _db.SaveChangesAsync(); - } - - private static string GenerateToken() - { - var bytes = RandomNumberGenerator.GetBytes(32); - return Convert.ToBase64String(bytes) - .Replace("+", "-") - .Replace("/", "_") - .TrimEnd('='); - } - } -} diff --git a/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs b/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs index e8d21c3..b7b7953 100644 --- a/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs +++ b/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs @@ -1,6 +1,8 @@ +using SeaHaven.Services.Interfaces; + namespace Api.SeaHavenIndustries.Helper { - public class ZipCodeDistance + public class ZipCodeDistance : IZipCodeDistance { private readonly Dictionary _coords = new(); private bool _loaded; diff --git a/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs b/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs new file mode 100644 index 0000000..a9223b5 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs @@ -0,0 +1,90 @@ +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public class DynamoSyncExternalSource : ISyncExternalSource + { + private readonly AmazonDynamoDBClient _dynamo; + private const string WO_TABLE = "WorkOrders"; + private const string COMMENTS_TABLE = "WorkOrderComments"; + private const string VENDOR_REPLIES_TABLE = "VendorReplies"; + + public DynamoSyncExternalSource() + { + _dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1); + } + + public async IAsyncEnumerable> ScanWorkOrdersAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? lastKey = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var request = new ScanRequest + { + TableName = WO_TABLE, + Limit = 100, + ExclusiveStartKey = lastKey + }; + var response = await _dynamo.ScanAsync(request); + foreach (var item in response.Items) + yield return ConvertItem(item); + lastKey = response.LastEvaluatedKey; + } while (lastKey != null && lastKey.Count > 0); + } + + public async IAsyncEnumerable> ScanCommentsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? lastKey = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var request = new ScanRequest + { + TableName = COMMENTS_TABLE, + Limit = 100, + ExclusiveStartKey = lastKey + }; + var response = await _dynamo.ScanAsync(request); + foreach (var item in response.Items) + yield return ConvertItem(item); + lastKey = response.LastEvaluatedKey; + } while (lastKey != null && lastKey.Count > 0); + } + + public async IAsyncEnumerable> ScanVendorRepliesAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? lastKey = null; + do + { + cancellationToken.ThrowIfCancellationRequested(); + var request = new ScanRequest + { + TableName = VENDOR_REPLIES_TABLE, + Limit = 100, + ExclusiveStartKey = lastKey + }; + var response = await _dynamo.ScanAsync(request); + foreach (var item in response.Items) + yield return ConvertItem(item); + lastKey = response.LastEvaluatedKey; + } while (lastKey != null && lastKey.Count > 0); + } + + private static Dictionary ConvertItem(Dictionary item) + { + var result = new Dictionary(); + foreach (var kvp in item) + { + if (kvp.Value.S != null) + result[kvp.Key] = kvp.Value.S; + } + return result; + } + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs new file mode 100644 index 0000000..308dd86 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public class FileStorageAdapter : IFileStoragePort + { + private readonly IWebHostEnvironment _webHostEnvironment; + private readonly IHttpContextAccessor _httpContext; + + public FileStorageAdapter(IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext) + { + _webHostEnvironment = webHostEnvironment; + _httpContext = httpContext; + } + + public async Task SaveFileAsync(IFormFile file) + { + var fileName = Path.GetFileName(file.FileName); + if (string.IsNullOrWhiteSpace(fileName)) + throw new InvalidDataException("The uploaded file must have a valid name."); + + var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; + var uploadPath = Path.Combine("Assets", "Documents"); + var webRoot = _webHostEnvironment.WebRootPath + ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); + var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName); + + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + + using (var stream = System.IO.File.Create(fullPath)) + { + await file.CopyToAsync(stream); + } + + var request = _httpContext.HttpContext?.Request + ?? throw new InvalidOperationException("An active HTTP request is required to build the file URL."); + var domain = $"{request.Scheme}://{request.Host}"; + return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; + } + } + + public class DispatchEmailAdapter : IDispatchEmailPort + { + private readonly Helper.SendMessage _sendMessage; + + public DispatchEmailAdapter(Helper.SendMessage sendMessage) + { + _sendMessage = sendMessage; + } + + public async Task SendDispatchEmailAsync(string emailTo, string subject, string htmlBody, string? replyTo) + { + return await _sendMessage.SendDispatchEmail(emailTo, subject, htmlBody, replyTo); + } + } + + public class VendorTokenAdapter : IVendorTokenPort + { + private readonly IVendorPortalTokenService _tokenService; + + public VendorTokenAdapter(IVendorPortalTokenService tokenService) + { + _tokenService = tokenService; + } + + public async Task GetOrCreateActiveTokenAsync(int vendorId) + { + var token = await _tokenService.GetOrCreateActiveTokenAsync( + vendorId, CancellationToken.None); + return token.Token; + } + } + + public class VendorDocumentStorageAdapter : IVendorDocumentStoragePort + { + private readonly IWebHostEnvironment _environment; + + public VendorDocumentStorageAdapter(IWebHostEnvironment environment) + { + _environment = environment; + } + + public async Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) + { + var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using var file = File.Create(path); + await content.CopyToAsync(file, cancellationToken); + } + + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) + { + var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName); + return File.OpenRead(path); + } + + public void Delete(int vendorId, int dispatchId, string storedFileName) + { + var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 0e6c2a4..d88536f 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -13,6 +13,7 @@ using System.Text; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; var builder = WebApplication.CreateBuilder(args); ConfigurationManager configuration = builder.Configuration; @@ -43,7 +44,8 @@ builder.Services.AddCors(option => option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddHostedService(); @@ -55,9 +57,16 @@ builder.Services.AddSingleton(sp if (File.Exists(csvPath)) zipService.Load(csvPath); return zipService; }); +builder.Services.AddSingleton( + serviceProvider => serviceProvider.GetRequiredService()); builder.Services.AddDataServices(); -builder.Services.AddBusinessServices(); +builder.Services.AddBusinessServices(configuration); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure( builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); @@ -75,6 +84,8 @@ builder.Services.Configure( builder.Services.Configure( builder.Configuration.GetSection(LegacyEndpointOptions.SectionName)); builder.Services.AddSingleton(); +builder.Services.AddSingleton( + sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService().State); builder.Services.AddScoped(); builder.Services.AddHostedService(); @@ -107,7 +118,9 @@ builder.Services.AddAuthentication(options => ValidateAudience = true, ValidAudience = configuration["JWT:ValidAudience"], ValidIssuer = configuration["JWT:ValidIssuer"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:Secret"])) + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( + configuration["JWT:Secret"] + ?? throw new InvalidOperationException("JWT:Secret configuration is required"))) }; }); builder.Services.AddEndpointsApiExplorer(); diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md index ee1d809..ca27a73 100644 --- a/BACKEND_ARCHITECTURE.md +++ b/BACKEND_ARCHITECTURE.md @@ -1,564 +1,106 @@ -# Backend Architecture Guide +# Backend Architecture and Quality Rules -## 🏗️ Clean Architecture Overview +This repository uses a feature-oriented service boundary over Entity Framework Core: -The backend follows **Clean Architecture** with strict layer separation: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ API LAYER (Controllers) │ -│ - HTTP Request/Response │ -│ - Authentication & Authorization │ -│ - Exception Handling │ -│ - Input Validation (basic) │ -└─────────────────┬───────────────────────────────────────────┘ - │ Calls - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ SERVICE LAYER (Business Logic) │ -│ - Business Rules │ -│ - FluentValidation (detailed validation) │ -│ - Data Transformation │ -│ - Orchestration │ -└─────────────────┬───────────────────────────────────────────┘ - │ Calls - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ DATA SERVICE LAYER (Persistence) │ -│ - Database Access │ -│ - Entity Operations (CRUD) │ -│ - Query Building │ -│ - Transaction Management │ -└─────────────────┬───────────────────────────────────────────┘ - │ Uses - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ DATA LAYER (EF Core) │ -│ - DbContext │ -│ - Entity Models │ -│ - Database Schema │ -└─────────────────────────────────────────────────────────────┘ +```text +HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext ``` ---- - -## 📁 Project Structure - -``` -backend/shoc-backend/ -│ -├── Api.SeaHavenIndustries/ # 🌐 API Layer -│ ├── Controllers/ # HTTP endpoints -│ ├── DTOs/ # Data Transfer Objects (API contracts) -│ ├── Program.cs # App startup & DI registration -│ └── appsettings.json # Configuration -│ -├── SeaHaven.Services/ # 💼 Service Layer (Business Logic) -│ ├── Interfaces/ # Service contracts (IContactService, etc.) -│ ├── Implementation/ # Service implementations -│ ├── DTOs/ # Internal DTOs for service layer -│ └── Validators/ # FluentValidation validators -│ -├── SeaHaven.DataServices/ # 💾 Data Access Layer -│ ├── Interfaces/ # DataService contracts (IContactDataService, etc.) -│ ├── Implementation/ # DataService implementations -│ └── DTOs/ # Data layer DTOs -│ -└── Data.SeaHavenIndustries/ # 🗄️ Data Layer (EF Core) - ├── Models/ # Entity models (Contact, Employee, etc.) - ├── ApplicationDbContext.cs # EF Core DbContext - └── Migrations/ # Database migrations -``` - ---- - -## 🔄 Data Flow Example (Creating a Contact) - -### 1️⃣ **API Layer** (`ContactController.cs`) -```csharp -[HttpPost("Create")] -public async Task AddContacts(Contact_DTO dto) -{ - try - { - // Get authenticated user ID - var userId = _userManager.GetUserId(User); - if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - - // Convert API DTO → Service DTO - var serviceDto = dto.ToServiceCreateDTO(); - - // Call Service Layer - await _contactService.CreateContactAsync(serviceDto, userId); - - return Ok(new Response { Status = "Success", Message = "Contact created" }); - } - catch (ValidationException vex) - { - var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); - return BadRequest(new Response { Status = "Validation Error", Message = errors }); - } - catch (KeyNotFoundException) - { - return NotFound(new Response { Status = "Error", Message = "Resource not found" }); - } - catch (Exception ex) - { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); - } -} -``` - -**Responsibilities:** -- ✅ Authenticate user -- ✅ Extract user ID from claims -- ✅ Map API DTO to Service DTO -- ✅ Catch exceptions and return proper HTTP status codes -- ❌ NO business logic -- ❌ NO database access - ---- - -### 2️⃣ **Service Layer** (`ContactService.cs`) - -```csharp -public class ContactService : IContactService -{ - private readonly IContactDataService _dataService; - private readonly IValidator _createValidator; - - public ContactService( - IContactDataService dataService, - IValidator createValidator) - { - _dataService = dataService; - _createValidator = createValidator; - } - - public async Task CreateContactAsync(CreateContactDTO dto, string userId) - { - // STEP 1: Validate using FluentValidation - var validationResult = await _createValidator.ValidateAsync(dto); - if (!validationResult.IsValid) - { - throw new ValidationException(validationResult.Errors); - } - - // STEP 2: Business logic (if any) - // Example: Check for duplicates, apply business rules, etc. - - // STEP 3: Map Service DTO → Data DTO - var dataDto = new CreateContactDataDTO - { - FirstName = dto.FirstName, - LastName = dto.LastName, - Email = dto.Email, - Phone = dto.Phone - }; - - // STEP 4: Call Data Service Layer - var createdContact = await _dataService.CreateAsync(dataDto, userId); - - // STEP 5: Map Data DTO → Service DTO and return - return new ContactDTO - { - Id = createdContact.Id, - FirstName = createdContact.FirstName, - LastName = createdContact.LastName, - Email = createdContact.Email, - Phone = createdContact.Phone - }; - } -} -``` - -**Responsibilities:** -- ✅ Validate input using FluentValidation -- ✅ Apply business rules -- ✅ Orchestrate operations -- ✅ Transform data between layers -- ❌ NO HTTP concerns -- ❌ NO direct database access - ---- - -### 3️⃣ **Data Service Layer** (`ContactDataService.cs`) - -```csharp -public class ContactDataService : IContactDataService -{ - private readonly ApplicationDbContext _db; - - public ContactDataService(ApplicationDbContext db) - { - _db = db; - } - - public async Task CreateAsync(CreateContactDataDTO dto, string userId) - { - // STEP 1: Create entity from DTO - var contact = new Contact - { - FirstName = dto.FirstName, - LastName = dto.LastName, - Email = dto.Email, - Phone = dto.Phone, - CreatedBy = userId, - CreatedDate = DateTime.UtcNow, - ModifiedBy = userId, - ModifiedDate = DateTime.UtcNow - }; - - // STEP 2: Add to database - _db.Contacts.Add(contact); - await _db.SaveChangesAsync(); - - // STEP 3: Map entity → Data DTO and return - return new ContactDataDTO - { - Id = contact.Id, - FirstName = contact.FirstName, - LastName = contact.LastName, - Email = contact.Email, - Phone = contact.Phone - }; - } -} -``` - -**Responsibilities:** -- ✅ Direct database operations (CRUD) -- ✅ Entity mapping -- ✅ Set audit fields (CreatedBy, CreatedDate, etc.) -- ✅ Transaction management -- ❌ NO validation -- ❌ NO business logic - ---- - -## 🔌 Dependency Injection (DI) - -### Automatic Assembly Scanning - -Services are **automatically registered** by scanning assemblies using naming convention. - -**In `Program.cs`:** -```csharp -builder.Services.AddDataServices(); // Auto-register all Data Services -builder.Services.AddBusinessServices(); // Auto-register all Business Services + Validators -``` - -### How It Works - -**Module Files:** -- `SeaHaven.DataServices/DependencyInjection/DataServicesModule.cs` -- `SeaHaven.Services/DependencyInjection/ServicesModule.cs` - -**Process:** -1. Scans assembly for all classes -2. Finds interface matching pattern `I{ClassName}` -3. Registers as Scoped service -4. Also auto-registers FluentValidation validators - -### Naming Convention Required - -| Interface | Implementation | Registered? | -|-----------|---------------|-------------| -| `IContactService` | `ContactService` | ✅ YES | -| `IContactDataService` | `ContactDataService` | ✅ YES | -| `ISomething` | `SomethingDifferent` | ❌ NO | - -**Rule**: Interface must be `I` + Class name exactly - -### Usage in Code - -Once registered, inject via constructor: - -```csharp -public class ContactController : Controller -{ - private readonly IContactService _contactService; - - public ContactController(IContactService contactService) - { - _contactService = contactService; - } -} -``` - -### Benefits - -✅ No manual registration - just follow naming convention -✅ Auto-discovery - new services work automatically -✅ FluentValidation auto-registered - ---- - -## ✅ FluentValidation - -### Validator Example (`CreateContactDTOValidator.cs`) - -```csharp -public class CreateContactDTOValidator : AbstractValidator -{ - public CreateContactDTOValidator() - { - RuleFor(x => x.FirstName) - .NotEmpty().WithMessage("First name is required") - .MaximumLength(50).WithMessage("First name cannot exceed 50 characters"); - - RuleFor(x => x.LastName) - .NotEmpty().WithMessage("Last name is required") - .MaximumLength(50).WithMessage("Last name cannot exceed 50 characters"); - - RuleFor(x => x.Email) - .NotEmpty().WithMessage("Email is required") - .EmailAddress().WithMessage("Invalid email format") - .MaximumLength(100).WithMessage("Email cannot exceed 100 characters"); - - RuleFor(x => x.Phone) - .Matches(@"^\+?[1-9]\d{1,14}$").When(x => !string.IsNullOrEmpty(x.Phone)) - .WithMessage("Invalid phone number format"); - } -} -``` - -### Where Validation Happens - -``` -❌ Controller → NO detailed validation (only catches ValidationException) -✅ Service → YES - FluentValidation runs here -❌ DataService → NO validation -``` - ---- - -## 🎯 Interface Examples - -### Service Interface (`IContactService.cs`) -```csharp -public interface IContactService -{ - Task CreateContactAsync(CreateContactDTO dto, string userId); - Task UpdateContactAsync(int id, UpdateContactDTO dto, string userId); - Task DeleteContactAsync(int id, string userId); - Task GetContactByIdAsync(int id); - Task> GetContactsPagedAsync(int page, int pageSize, string? search); -} -``` - -### Data Service Interface (`IContactDataService.cs`) -```csharp -public interface IContactDataService -{ - Task CreateAsync(CreateContactDataDTO dto, string userId); - Task UpdateAsync(int id, UpdateContactDataDTO dto, string userId); - Task DeleteAsync(int id, string userId); - Task GetByIdAsync(int id); - Task> GetPagedAsync(int page, int pageSize, string? search); -} -``` - ---- - -## 📦 DTO Types - -### 1. **API DTOs** (`Api.SeaHavenIndustries/DTOs/`) -- Used for HTTP requests/responses -- Map to frontend JSON structure -- Example: `Contact_DTO`, `EditContact_DTO` - -### 2. **Service DTOs** (`SeaHaven.Services/DTOs/`) -- Used between Controller ↔ Service -- Business-focused structure -- Example: `CreateContactDTO`, `UpdateContactDTO`, `ContactDTO` - -### 3. **Data DTOs** (`SeaHaven.DataServices/DTOs/`) -- Used between Service ↔ DataService -- Database-focused structure -- Example: `CreateContactDataDTO`, `ContactDataDTO` - -### DTO Mapping Flow -``` -API DTO → Service DTO → Data DTO → Entity -(HTTP) (Business) (Data) (Database) -``` - ---- - -## 🚨 Exception Handling Pattern - -### Controller Exception Handling (STANDARD PATTERN) - -```csharp -[HttpPost("Create")] -public async Task Create([FromBody] SomeDTO dto) -{ - try - { - // Get user ID - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - - // Call service - await _service.CreateAsync(dto, userId); - - return Ok(new Response { Status = "Success", Message = "Created successfully" }); - } - catch (ValidationException vex) // ⚠️ CATCH THIS FIRST - { - var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); - return BadRequest(new Response { Status = "Validation Error", Message = errors }); - } - catch (KeyNotFoundException) // ⚠️ THEN THIS - { - return NotFound(new Response { Status = "Error", Message = "Not found" }); - } - catch (Exception ex) // ⚠️ GENERIC LAST - { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); - } -} -``` - -### HTTP Status Code Mapping -- **200 OK** → Success -- **400 Bad Request** → Validation errors -- **401 Unauthorized** → Not authenticated -- **404 Not Found** → Resource not found -- **500 Internal Server Error** → Unexpected errors - ---- - -## 🔐 Authentication & User ID - -### Getting User ID (Two Methods) - -**Method 1: Using Claims (Preferred)** -```csharp -var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); -if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); -``` - -**Method 2: Using UserManager (for Identity)** -```csharp -var userId = _userManager.GetUserId(User); -if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); -``` - -### Audit Fields -Every entity should track: -- `CreatedBy` (string - User ID GUID) -- `CreatedDate` (DateTime) -- `ModifiedBy` (string - User ID GUID) -- `ModifiedDate` (DateTime) - ---- - -## 📋 Naming Conventions - -### Controllers -- Named after entity + "Controller" -- Example: `ContactController`, `EmployeeController` - -### Services -- Interface: `I[Entity]Service` -- Implementation: `[Entity]Service` -- Example: `IContactService`, `ContactService` - -### Data Services -- Interface: `I[Entity]DataService` -- Implementation: `[Entity]DataService` -- Example: `IContactDataService`, `ContactDataService` - -### DTOs -- API Layer: `[Entity]_DTO`, `Edit[Entity]_DTO` -- Service Layer: `Create[Entity]DTO`, `Update[Entity]DTO`, `[Entity]DTO` -- Data Layer: `Create[Entity]DataDTO`, `[Entity]DataDTO` - ---- - -## ✅ Best Practices Checklist - -### Controllers -- [ ] Use dependency injection for services -- [ ] Get user ID from `User.FindFirstValue(ClaimTypes.NameIdentifier)` -- [ ] Return 401 if userId is null -- [ ] Catch `ValidationException` BEFORE generic `Exception` -- [ ] Return appropriate HTTP status codes -- [ ] NO business logic in controllers -- [ ] NO direct database access (`_db`) - -### Services -- [ ] Validate using FluentValidation -- [ ] Throw `ValidationException` for validation errors -- [ ] Throw `KeyNotFoundException` for not found -- [ ] Pass userId to DataService -- [ ] Transform DTOs between layers -- [ ] Implement business rules here - -### Data Services -- [ ] Direct database access only -- [ ] Set audit fields (CreatedBy, CreatedDate, ModifiedBy, ModifiedDate) -- [ ] Use async/await for all database operations -- [ ] NO validation logic -- [ ] NO business logic - ---- - -## 🎓 Quick Reference - -| Layer | Purpose | Can Access | Cannot Access | -|-------|---------|-----------|---------------| -| **Controller** | HTTP handling, auth | Service layer | Database directly | -| **Service** | Business logic, validation | DataService layer | Database directly | -| **DataService** | Database operations | DbContext, Entities | HTTP context | -| **Data** | Entity definitions | Nothing (POCOs) | Application logic | - ---- - -## 📚 Example: Full CRUD Implementation - -See these files for complete examples: -- **Contact**: `ContactController.cs`, `ContactService.cs`, `ContactDataService.cs` -- **Employee**: `EmployeeController.cs`, `EmployeeService.cs`, `EmployeeDataService.cs` -- **Asset**: `AssetController.cs`, `AssetService.cs`, `AssetDataService.cs` - -All follow the same Clean Architecture pattern! - ---- - -## 🔐 Configuration & Secrets - -No secrets are stored in source. The committed `appsettings*.json` files hold only -`${PLACEHOLDER}` tokens; real values must be supplied at runtime through one of the -standard .NET configuration sources (later sources win): - -1. `appsettings.json` / `appsettings.{Environment}.json` — committed, placeholders only. -2. **User Secrets** (local dev): `dotnet user-secrets set "Section:Key" "value"`. -3. **Environment variables** — use `__` (double underscore) as the section separator. - In AWS Elastic Beanstalk these are the environment properties. - -See `.env.example` in the repo root for the full list. Required values: - -| Setting | Env-var form | Used by | Notes | -|---|---|---|---| -| `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` | Both projects | SQL Server connection string | -| `SendGrid:ApiKey` | `SendGrid__ApiKey` | Both projects | Transactional email | -| `JWT:Secret` | `JWT__Secret` | `Api.SeaHavenIndustries` | JWT signing key | -| `GoogleMaps:ApiKey` | `GoogleMaps__ApiKey` | `SeaHavenIndustries` (Blazor) | Maps/Places; read in `App.razor` + `Home.razor` | - -### AWS credentials - -`UploadFileHp` (S3 uploads) uses the **AWS SDK default credential chain** — it no longer -hardcodes access keys. Credentials resolve from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` -env vars, the shared profile/SSO, or (preferred in production) the EC2/ECS instance role. -The region comes from `AddDefaultAWSOptions` in `Program.cs`. - -> ⚠️ The secrets previously committed to this repo (DB password, SendGrid key, JWT secret, -> Google Maps keys, AWS access key) should be considered compromised and **rotated**. - +The interfaces are architectural seams, not a requirement to wrap every class or every +EF method. Add an interface when it separates HTTP, business, persistence, or external +I/O responsibilities and enables behavior-focused testing. + +## Layer responsibilities + +### API controllers + +- Parse HTTP input, authorize the caller, invoke a business-service interface, and map + the result to the established HTTP contract. +- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete + data-service implementations. +- Do not contain persistence queries or business workflows. +- Do not expose exception messages to clients. Map known failures explicitly and let the + centralized exception boundary handle unexpected failures. + +### Business services + +- Own validation, business decisions, orchestration, and transaction intent. +- Depend on interfaces for persistence and external I/O. +- Use `IOptions` for typed configuration. Do not inject `IConfiguration`. +- Keep feature responsibilities cohesive. Split a service when it has independently + changing business reasons, not because it crossed an arbitrary line count. +- Preserve existing API and board-backed behavior unless the accepted requirement + explicitly changes it. + +### Data services + +- Own EF Core query shape, persistence, batching, and transaction implementation. +- Accept cancellation tokens on new I/O methods and pass them to EF Core. +- Use `AsNoTracking()` for read-only queries. +- Project only required columns for list/read models; include full graphs only when the + caller needs them. +- Page unbounded collections at the database. +- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes + while preserving the workflow's failure and transaction semantics. +- Do not introduce a generic repository over EF Core. Feature data services should expose + intent-revealing operations. + +## Performance review + +For each changed hot path, document and test: + +- input size variables; +- CPU and memory complexity; +- database round trips; +- whether filtering, ordering, and paging execute in SQL; +- whether tracking is necessary; +- whether repeated work can be hoisted out of loops; +- the partial-failure and transaction behavior. + +Prefer dictionary or hash-set reconstruction when database results must follow caller +order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory; +repeated `First`/`Single` scans over the result are `O(n²)`. + +Do not optimize by weakening correctness. In particular, batching all writes into one +final save can change partial-success behavior, and adding a transaction can change +locking and retry semantics. Those are business decisions, not mechanical cleanup. + +## Configuration + +Configuration is bound once during composition in +`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive +typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and +`VendorPortalOptions`. + +Defaults must preserve current behavior. Startup validation should be introduced only +when every deployed environment is known to provide the required value. + +## Enforcement + +`ArchitectureTests` enforces mechanical dependency rules: + +- controllers cannot inject EF contexts or concrete service/data-service classes; +- business services cannot inject EF contexts or raw `IConfiguration`. + +The pull-request quality workflow runs those tests and verifies formatting for changed +C# files. The normal CI workflow builds the solution and runs the full test suite. + +Architecture tests are appropriate for dependency direction. Behavior belongs in tests +through public interfaces. Do not add tests merely to prove a linter or analyzer itself +works; test the product behavior the rule protects. + +## Review checklist + +- Controller depends only on service abstractions. +- Business logic is in a cohesive feature service. +- EF operations are behind a feature data-service abstraction. +- No raw `IConfiguration` in a business service. +- No unbounded load followed by in-memory filtering. +- No query/save loop where a set-based operation preserves semantics. +- Read-only EF queries use no tracking. +- New async I/O accepts and propagates cancellation where the public contract permits. +- Errors do not leak internal exception details. +- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing + records, and relevant failure boundaries. +- Changed behavior has been checked against the authoritative ticket board. Missing board + access blocks readiness and merge claims. diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs new file mode 100644 index 0000000..c2097f0 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -0,0 +1,101 @@ +namespace Data.SeaHavenIndustries +{ + public class VendorDispatchSummaryData + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public string? WorkOrderTitle { get; set; } + public string? InternalWONumber { get; set; } + public string? LocationName { get; set; } + public string? LocationCity { get; set; } + public string? LocationState { get; set; } + } + + public class PortalChecklistItemData + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class PortalSignoffData + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class PortalUpliftData + { + public int Id { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByFirstName { get; set; } + public string? DecidedByLastName { get; set; } + } + + public class PortalCommentData + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public DateTime? CreatedDate { get; set; } + public string? UserId { get; set; } + public string? UserFirstName { get; set; } + public string? UserLastName { get; set; } + } + + public class UpliftListItemData + { + public int Id { get; set; } + public int DispatchId { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public int? WorkOrderId { get; set; } + public string? VendorCompanyName { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public int RequiredTier { get; set; } + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + } + + public class UpliftForDispatchData + { + public int Id { get; set; } + public int DispatchId { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByFirstName { get; set; } + public string? DecidedByLastName { get; set; } + } +} diff --git a/SeaHaven.DataServices/Dto/DispatchReadModels.cs b/SeaHaven.DataServices/Dto/DispatchReadModels.cs new file mode 100644 index 0000000..45dc210 --- /dev/null +++ b/SeaHaven.DataServices/Dto/DispatchReadModels.cs @@ -0,0 +1,169 @@ +namespace SeaHaven.DataServices.Dto +{ + public class DispatchListItemDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public decimal? NTEAmount { get; set; } + public string? Status { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public DateTime? VerifiedAt { get; set; } + public int? VendorId { get; set; } + public string? VendorName { get; set; } + public int? WorkOrderId { get; set; } + public string? PrimaryWOInternalNumber { get; set; } + public string? PrimaryWOTitle { get; set; } + public string? LocationName { get; set; } + public int WorkOrderCount { get; set; } + public int PendingUpliftCount { get; set; } + } + + public class DispatchListResult + { + public IReadOnlyList Items { get; set; } = Array.Empty(); + public int Total { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + } + + public class DispatchListQuery + { + public string? Search { get; set; } + public string? Status { get; set; } + public int? VendorId { get; set; } + public DateTime? DateFrom { get; set; } + public DateTime? DateTo { get; set; } + public string? SortBy { get; set; } + public string? SortDir { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + } + + public class DispatchDetailVendorDTO + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? TradeSpecialties { get; set; } + } + + public class DispatchPrimaryLocationDTO + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Address1 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + } + + public class DispatchLinkedWorkOrderDTO + { + public int WorkOrderId { get; set; } + public string? InternalWONumber { get; set; } + public string? Title { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public string? LocationName { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public string? Problem { get; set; } + } + + public class DispatchChecklistDTO + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + public int SortOrder { get; set; } + public int? WorkOrderId { get; set; } + } + + public class DispatchSignoffDTO + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? Signature { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class DispatchCommentDTO + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public string? RecordType { get; set; } + public DateTime? CreatedDate { get; set; } + public string? UserId { get; set; } + public string UserName { get; set; } = ""; + } + + public class DispatchUpliftRequestDTO + { + public int Id { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public decimal Delta { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string DecidedByName { get; set; } = ""; + } + + public class DispatchAuditLogDTO + { + public int Id { get; set; } + public string? Action { get; set; } + public string? FieldName { get; set; } + public string? OldValue { get; set; } + public string? NewValue { get; set; } + public DateTime? CreatedAt { get; set; } + public string? UserId { get; set; } + public string UserName { get; set; } = ""; + } + + public class DispatchDetailDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public DateTime? VerifiedAt { get; set; } + public string? VerifiedBy { get; set; } + public bool EmailSent { get; set; } + public int? WorkOrderId { get; set; } + public DispatchDetailVendorDTO? Vendor { get; set; } + public DispatchPrimaryLocationDTO? PrimaryLocation { get; set; } + public IReadOnlyList WorkOrders { get; set; } = Array.Empty(); + public IReadOnlyList Checklist { get; set; } = Array.Empty(); + public IReadOnlyList Signoffs { get; set; } = Array.Empty(); + public IReadOnlyList Comments { get; set; } = Array.Empty(); + public IReadOnlyList UpliftRequests { get; set; } = Array.Empty(); + public IReadOnlyList AuditLog { get; set; } = Array.Empty(); + } +} diff --git a/SeaHaven.DataServices/Dto/SettingsReadModels.cs b/SeaHaven.DataServices/Dto/SettingsReadModels.cs new file mode 100644 index 0000000..99e4057 --- /dev/null +++ b/SeaHaven.DataServices/Dto/SettingsReadModels.cs @@ -0,0 +1,8 @@ +namespace SeaHaven.DataServices.Dto +{ + public class SettingsCategoryItemDTO + { + public int Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/SeaHaven.DataServices/Dto/UserReadModels.cs b/SeaHaven.DataServices/Dto/UserReadModels.cs new file mode 100644 index 0000000..c3cb153 --- /dev/null +++ b/SeaHaven.DataServices/Dto/UserReadModels.cs @@ -0,0 +1,21 @@ +namespace SeaHaven.DataServices.Dto +{ + public sealed class UserListRowData + { + public required string Id { get; init; } + public string? RoleName { get; init; } + public string? Email { get; init; } + public string? Name { get; init; } + public DateTime? CreatedDate { get; init; } + public string? Status { get; init; } + } + + public sealed class UserProfileData + { + public required string Id { get; init; } + public string? FirstName { get; init; } + public string? Email { get; init; } + public string? Contact { get; init; } + public DateTime? CreatedDate { get; init; } + } +} diff --git a/SeaHaven.DataServices/Implementation/CalendarDataService.cs b/SeaHaven.DataServices/Implementation/CalendarDataService.cs new file mode 100644 index 0000000..105ecb0 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/CalendarDataService.cs @@ -0,0 +1,85 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class CalendarDataService : ICalendarDataService + { + private readonly ApplicationDbContext _context; + + public CalendarDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetEventByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.Events + .AsNoTracking() + .Where(e => e.Id == id && e.IsDeleted != true) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetEventForEditAsync(int id, CancellationToken cancellationToken) + { + return await _context.Events.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task AddEventAsync(Events ev, CancellationToken cancellationToken) + { + await _context.Events.AddAsync(ev, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return ev; + } + + public async Task UpdateEventAsync(Events ev, CancellationToken cancellationToken) + { + _context.Events.Update(ev); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task<(IEnumerable Items, int TotalCount)> GetEventListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken) + { + var query = _context.Events.Where(e => e.IsDeleted != true); + + if (!string.IsNullOrWhiteSpace(search)) + { + var searchLower = search.ToLower(); + query = query.Where(e => + e.Title.ToLower().Contains(searchLower) || + (e.Description != null && e.Description.ToLower().Contains(searchLower)) || + (e.Location != null && e.Location.ToLower().Contains(searchLower))); + } + + var totalCount = await query.CountAsync(cancellationToken); + + var events = await query + .OrderByDescending(e => e.StartDate) + .ThenByDescending(e => e.CreatedDate) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .AsNoTracking() + .ToListAsync(cancellationToken); + + return (events, totalCount); + } + + public async Task> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken) + { + var query = _context.Events.Where(e => e.IsDeleted != true); + + if (start.HasValue && end.HasValue) + { + query = query.Where(e => + (e.StartDate >= start.Value && e.StartDate <= end.Value) || + (e.EndDate >= start.Value && e.EndDate <= end.Value) || + (e.StartDate <= start.Value && e.EndDate >= end.Value)); + } + + return await query + .AsNoTracking() + .ToListAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/CommentDataService.cs b/SeaHaven.DataServices/Implementation/CommentDataService.cs index 72db1a1..705beaf 100644 --- a/SeaHaven.DataServices/Implementation/CommentDataService.cs +++ b/SeaHaven.DataServices/Implementation/CommentDataService.cs @@ -71,5 +71,32 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Comments.AnyAsync(c => c.Id == id); } + + public async Task StageAsync(Comments comment, CancellationToken cancellationToken) + { + await _context.Comments.AddAsync(comment, cancellationToken); + } + + public async Task> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from c in _context.Comments + where c.DispatchId == dispatchId + && c.CommentType != "internal" + && (c.IsDeleted == null || c.IsDeleted == false) + join u in _context.Users on c.UserId equals u.Id into users + from u in users.DefaultIfEmpty() + orderby c.CreatedDate + select new PortalCommentData + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = c.Commenter, + CommentType = c.CommentType, + CreatedDate = c.CreatedDate, + UserId = c.UserId, + UserFirstName = u != null ? u.FirstName : null, + UserLastName = u != null ? u.LastName : null + }).ToListAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/CommonDataService.cs b/SeaHaven.DataServices/Implementation/CommonDataService.cs new file mode 100644 index 0000000..b263155 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/CommonDataService.cs @@ -0,0 +1,81 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class CommonDataService : ICommonDataService + { + private readonly ApplicationDbContext _context; + + public CommonDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task<(IEnumerable Items, int TotalCount)> GetUsersPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken) + { + var query = _context.Users + .Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower())) + .OrderByDescending(d => d.Id); + + var totalCount = await query.CountAsync(cancellationToken); + + var paginatedUsers = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .AsNoTracking() + .ToListAsync(cancellationToken); + + return (paginatedUsers, totalCount); + } + + public async Task> GetRoleNamesAsync(CancellationToken cancellationToken) + { + return await _context.Roles + .AsNoTracking() + .Select(s => s.Name) + .ToListAsync(cancellationToken); + } + + public async Task> GetSiteNamesAsync(CancellationToken cancellationToken) + { + return await _context.Locations + .AsNoTracking() + .Select(n => new Locations + { + Id = n.Id, + Name = n.Name, + Title = n.Title + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetContactsAsync(CancellationToken cancellationToken) + { + return await _context.Contacts + .AsNoTracking() + .Select(n => new Contacts + { + Id = n.Id, + FirstName = n.FirstName, + LastName = n.LastName, + Email = n.Email, + PhoneNumber = n.PhoneNumber + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetCategoriesAsync(CancellationToken cancellationToken) + { + return await _context.Categories + .AsNoTracking() + .Select(s => new Category + { + Id = s.Id, + Name = s.Name + }) + .ToListAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs new file mode 100644 index 0000000..d1e29c3 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -0,0 +1,34 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class DashboardDataService : IDashboardDataService + { + private readonly ApplicationDbContext _context; + + public DashboardDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetWorkOrderCountsAsync(CancellationToken cancellationToken) + { + var query = _context.workOrders.Where(w => w.istemplate != true); + + var total = await query.CountAsync(cancellationToken); + var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken); + var notDispatched = await query.CountAsync(w => (w.Status == "Open") && (w.AssignTo == null || w.AssignTo == ""), cancellationToken); + var completed = await query.CountAsync(w => w.Status == "Done", cancellationToken); + + return new DashboardCounts + { + Total = total, + Open = open, + NotDispatched = notDispatched, + Completed = completed + }; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index 4a057f6..16d5747 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -1,6 +1,8 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -25,6 +27,14 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(d => d.Id == id); } + public async Task GetByIdWithVendorAndWorkOrderAsync(int id) + { + return await _context.Dispatches + .Include(dispatch => dispatch.Vendor) + .Include(dispatch => dispatch.WorkOrder) + .FirstOrDefaultAsync(dispatch => dispatch.Id == id); + } + public async Task> GetAllAsync() { return await _context.Dispatches.ToListAsync(); @@ -45,9 +55,9 @@ namespace SeaHaven.DataServices.Implementation } public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( - int page, - int pageSize, - string? search = null, + int page, + int pageSize, + string? search = null, string? status = null) { var query = _context.Dispatches.AsQueryable(); @@ -216,5 +226,576 @@ namespace SeaHaven.DataServices.Implementation await _context.DispatchWorkOrders.AddAsync(dispatchWorkOrder); await _context.SaveChangesAsync(); } + + public async Task GetListAsync(DispatchListQuery query, CancellationToken cancellationToken) + { + var q = _context.Dispatches + .Include(d => d.Vendor) + .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) + .AsNoTracking() + .Where(d => d.IsDeleted == null || d.IsDeleted == false); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var s = query.Search.ToLower(); + q = q.Where(d => + (d.DispatchNumber ?? "").ToLower().Contains(s) || + (d.PONumber ?? "").ToLower().Contains(s) || + (d.Vendor != null && (d.Vendor.CompanyName ?? "").ToLower().Contains(s)) || + (d.WorkOrder != null && (d.WorkOrder.InternalWONumber ?? "").ToLower().Contains(s)) || + (d.WorkOrder != null && (d.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(s)) || + (d.WorkOrder != null && d.WorkOrder.Locations != null && (d.WorkOrder.Locations.Name ?? "").ToLower().Contains(s)) + ); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + q = q.Where(d => d.Status == query.Status); + + if (query.VendorId.HasValue) + q = q.Where(d => d.VendorId == query.VendorId.Value); + + if (query.DateFrom.HasValue) + q = q.Where(d => d.DispatchedAt >= query.DateFrom.Value); + + if (query.DateTo.HasValue) + { + var end = query.DateTo.Value.Date.AddDays(1); + q = q.Where(d => d.DispatchedAt < end); + } + + var total = await q.CountAsync(cancellationToken); + + var dir = (query.SortDir ?? "desc").ToLowerInvariant() == "asc"; + switch ((query.SortBy ?? "").ToLowerInvariant()) + { + case "ponumber": + q = dir ? q.OrderBy(d => d.PONumber) : q.OrderByDescending(d => d.PONumber); + break; + case "dispatchnumber": + q = dir ? q.OrderBy(d => d.DispatchNumber) : q.OrderByDescending(d => d.DispatchNumber); + break; + case "vendor": + q = dir ? q.OrderBy(d => d.Vendor!.CompanyName) : q.OrderByDescending(d => d.Vendor!.CompanyName); + break; + case "nte": + case "nteamount": + q = dir ? q.OrderBy(d => d.NTEAmount) : q.OrderByDescending(d => d.NTEAmount); + break; + case "status": + q = dir ? q.OrderBy(d => d.Status) : q.OrderByDescending(d => d.Status); + break; + case "scheduleddate": + q = dir ? q.OrderBy(d => d.ScheduledDate) : q.OrderByDescending(d => d.ScheduledDate); + break; + case "completeddate": + q = dir ? q.OrderBy(d => d.CompletedDate) : q.OrderByDescending(d => d.CompletedDate); + break; + default: + q = dir ? q.OrderBy(d => d.DispatchedAt) : q.OrderByDescending(d => d.DispatchedAt); + break; + } + + var rows = await q + .Skip((Math.Max(query.Page, 1) - 1) * query.PageSize) + .Take(query.PageSize) + .Select(d => new DispatchListItemDTO + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + NTEAmount = d.NTEAmount, + Status = d.Status, + DispatchedAt = d.DispatchedAt, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + AcknowledgedAt = d.AcknowledgedAt, + VerifiedAt = d.VerifiedAt, + VendorId = d.VendorId, + VendorName = d.Vendor != null ? d.Vendor.CompanyName : null, + WorkOrderId = d.WorkOrderId, + PrimaryWOInternalNumber = d.WorkOrder != null ? d.WorkOrder.InternalWONumber : null, + PrimaryWOTitle = d.WorkOrder != null ? d.WorkOrder.WorkerOrderTitle : null, + LocationName = d.WorkOrder != null && d.WorkOrder.Locations != null ? d.WorkOrder.Locations.Name : null, + WorkOrderCount = _context.DispatchWorkOrders.Count(dwo => dwo.DispatchId == d.Id), + PendingUpliftCount = _context.DispatchUpliftRequests.Count(u => u.DispatchId == d.Id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false)) + }) + .ToListAsync(cancellationToken); + + return new DispatchListResult + { + Items = rows, + Total = total, + Page = query.Page, + PageSize = query.PageSize + }; + } + + public async Task GetDetailAsync(int id, CancellationToken cancellationToken) + { + var dispatch = await _context.Dispatches + .Include(d => d.Vendor) + .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) + .AsNoTracking() + .FirstOrDefaultAsync(d => d.Id == id, cancellationToken); + if (dispatch == null) return null; + + var linked = await _context.DispatchWorkOrders + .AsNoTracking() + .Where(dwo => dwo.DispatchId == id) + .Select(dwo => new DispatchLinkedWorkOrderDTO + { + WorkOrderId = dwo.WorkOrderId, + InternalWONumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : null, + Title = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : null, + Priority = dwo.WorkOrder != null ? dwo.WorkOrder.Priority : null, + Status = dwo.WorkOrder != null ? dwo.WorkOrder.Status : null, + LocationName = dwo.WorkOrder != null && dwo.WorkOrder.Locations != null ? dwo.WorkOrder.Locations.Name : null, + Trade = dwo.WorkOrder != null ? dwo.WorkOrder.Trade : null, + SubTrade = dwo.WorkOrder != null ? dwo.WorkOrder.SubTrade : null, + Problem = dwo.WorkOrder != null ? dwo.WorkOrder.Problem : null + }) + .ToListAsync(cancellationToken); + + var linkedWOs = linked; + if (linkedWOs.Count == 0 && dispatch.WorkOrderId.HasValue) + { + var wo = await _context.workOrders + .Include(w => w.Locations) + .AsNoTracking() + .FirstOrDefaultAsync(w => w.Id == dispatch.WorkOrderId.Value, cancellationToken); + if (wo != null) + { + linkedWOs = new List + { + new DispatchLinkedWorkOrderDTO + { + WorkOrderId = wo.Id, + InternalWONumber = wo.InternalWONumber, + Title = wo.WorkerOrderTitle, + Priority = wo.Priority, + Status = wo.Status, + LocationName = wo.Locations != null ? wo.Locations.Name : null, + Trade = wo.Trade, + SubTrade = wo.SubTrade, + Problem = wo.Problem + } + }; + } + } + + var checklist = await _context.DispatchChecklistItems + .AsNoTracking() + .Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false)) + .OrderBy(c => c.SortOrder) + .Select(c => new DispatchChecklistDTO + { + Id = c.Id, + ItemText = c.ItemText, + IsCompleted = c.IsCompleted, + CompletedBy = c.CompletedBy, + CompletedAt = c.CompletedAt, + SortOrder = c.SortOrder, + WorkOrderId = c.WorkOrderId + }) + .ToListAsync(cancellationToken); + + var signoffs = await _context.DispatchSignoffs + .AsNoTracking() + .Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false)) + .Select(s => new DispatchSignoffDTO + { + Id = s.Id, + SignoffType = s.SignoffType, + Name = s.Name, + Signature = s.Signature, + SignatureMethod = s.SignatureMethod, + SignedAt = s.SignedAt + }) + .ToListAsync(cancellationToken); + + var rawComments = await (from c in _context.Comments + where c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false) + join u in _context.Users on c.UserId equals u.Id into users + from u in users.DefaultIfEmpty() + orderby c.CreatedDate + select new + { + c.Id, + c.Commenttext, + c.Commenter, + c.CommentType, + c.RecordType, + c.CreatedDate, + c.UserId, + UserFirstName = u != null ? u.FirstName : null, + UserLastName = u != null ? u.LastName : null + }) + .ToListAsync(cancellationToken); + + var comments = rawComments.Select(c => new DispatchCommentDTO + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = c.Commenter, + CommentType = c.CommentType, + RecordType = c.RecordType, + CreatedDate = c.CreatedDate, + UserId = c.UserId, + UserName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() + }).ToList(); + + var rawUplifts = await (from u in _context.DispatchUpliftRequests + where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false) + join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs + from dec in decs.DefaultIfEmpty() + orderby u.CreatedDate descending + select new + { + u.Id, + u.CurrentNTE, + u.RequestedNTE, + u.VendorReason, + u.Status, + u.RequiredTier, + u.RequestedByVendorName, + RequestedAt = u.CreatedDate, + u.DecidedAt, + u.DecisionNote, + DecidedByFirstName = dec != null ? dec.FirstName : null, + DecidedByLastName = dec != null ? dec.LastName : null + }) + .ToListAsync(cancellationToken); + + var upliftRequests = rawUplifts.Select(u => new DispatchUpliftRequestDTO + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m), + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + RequestedAt = u.RequestedAt, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() + }).ToList(); + + var dispatchNumber = dispatch.DispatchNumber ?? ""; + var workOrderIds = linkedWOs.Select(l => l.WorkOrderId).Where(wid => wid > 0).ToList(); + + var rawAudit = await (from a in _context.WorkOrderAuditLogs + where workOrderIds.Contains(a.WorkOrderId) + && a.FieldName != null + && a.FieldName.Contains(dispatchNumber) + join u in _context.Users on a.UserId equals u.Id into users + from u in users.DefaultIfEmpty() + orderby a.CreatedAt descending + select new + { + a.Id, + a.Action, + a.FieldName, + a.OldValue, + a.NewValue, + a.CreatedAt, + a.UserId, + UserFirstName = u != null ? u.FirstName : null, + UserLastName = u != null ? u.LastName : null + }) + .ToListAsync(cancellationToken); + + var auditLog = rawAudit.Select(a => new DispatchAuditLogDTO + { + Id = a.Id, + Action = a.Action, + FieldName = a.FieldName, + OldValue = a.OldValue, + NewValue = a.NewValue, + CreatedAt = a.CreatedAt, + UserId = a.UserId, + UserName = string.Join(" ", new[] { a.UserFirstName, a.UserLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() + }).ToList(); + + return new DispatchDetailDTO + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + NTEAmount = dispatch.NTEAmount, + Description = dispatch.Description, + Status = dispatch.Status, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + AcknowledgedAt = dispatch.AcknowledgedAt, + VerifiedAt = dispatch.VerifiedAt, + VerifiedBy = dispatch.VerifiedBy, + EmailSent = dispatch.EmailSent, + WorkOrderId = dispatch.WorkOrderId, + Vendor = dispatch.Vendor == null ? null : new DispatchDetailVendorDTO + { + Id = dispatch.Vendor.Id, + CompanyName = dispatch.Vendor.CompanyName, + ContactName = dispatch.Vendor.ContactName, + Email = dispatch.Vendor.Email, + Phone = dispatch.Vendor.Phone, + Address = dispatch.Vendor.Address, + City = dispatch.Vendor.City, + State = dispatch.Vendor.State, + Zip = dispatch.Vendor.Zip, + TradeSpecialties = dispatch.Vendor.TradeSpecialties + }, + PrimaryLocation = dispatch.WorkOrder?.Locations == null ? null : new DispatchPrimaryLocationDTO + { + Id = dispatch.WorkOrder.Locations.Id, + Name = dispatch.WorkOrder.Locations.Name, + Address1 = dispatch.WorkOrder.Locations.Address1, + City = dispatch.WorkOrder.Locations.City, + State = dispatch.WorkOrder.Locations.State, + Zip = dispatch.WorkOrder.Locations.Zip + }, + WorkOrders = linkedWOs, + Checklist = checklist, + Signoffs = signoffs, + Comments = comments, + UpliftRequests = upliftRequests, + AuditLog = auditLog + }; + } + + public async Task StageSignoffAsync( + DispatchSignoff signoff, CancellationToken cancellationToken) + { + await _context.DispatchSignoffs.AddAsync(signoff, cancellationToken); + } + + public async Task> GetVendorDispatchSummariesAsync( + int vendorId, string? status, CancellationToken cancellationToken) + { + var query = _context.Dispatches + .AsNoTracking() + .Where(dispatch => dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false)); + + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(dispatch => dispatch.Status == status); + + return await query + .OrderByDescending(dispatch => dispatch.DispatchedAt ?? dispatch.CreatedDate) + .Select(dispatch => new VendorDispatchSummaryData + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + Status = dispatch.Status, + NTEAmount = dispatch.NTEAmount, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + AcknowledgedAt = dispatch.AcknowledgedAt, + WorkOrderTitle = dispatch.WorkOrder != null + ? dispatch.WorkOrder.WorkerOrderTitle + : null, + InternalWONumber = dispatch.WorkOrder != null + ? dispatch.WorkOrder.InternalWONumber + : null, + LocationName = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.Name + : null, + LocationCity = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.City + : null, + LocationState = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.State + : null + }) + .ToListAsync(cancellationToken); + } + + public async Task GetVendorDispatchDetailAsync( + int id, int vendorId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .Include(dispatch => dispatch.WorkOrder) + .ThenInclude(workOrder => workOrder!.Locations) + .AsNoTracking() + .FirstOrDefaultAsync(dispatch => dispatch.Id == id + && dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false), + cancellationToken); + } + + public async Task GetVendorDispatchForMutationAsync( + int id, int vendorId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .Include(dispatch => dispatch.WorkOrder) + .ThenInclude(workOrder => workOrder!.Locations) + .FirstOrDefaultAsync(dispatch => dispatch.Id == id + && dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false), + cancellationToken); + } + + public async Task> GetPortalChecklistAsync( + int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchChecklistItems + .AsNoTracking() + .Where(item => item.DispatchId == dispatchId + && (item.IsDeleted == null || item.IsDeleted == false)) + .OrderBy(item => item.SortOrder) + .Select(item => new PortalChecklistItemData + { + Id = item.Id, + ItemText = item.ItemText, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetPortalSignoffsAsync( + int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchSignoffs + .AsNoTracking() + .Where(signoff => signoff.DispatchId == dispatchId + && (signoff.IsDeleted == null || signoff.IsDeleted == false)) + .Select(signoff => new PortalSignoffData + { + Id = signoff.Id, + SignoffType = signoff.SignoffType, + Name = signoff.Name, + SignatureMethod = signoff.SignatureMethod, + SignedAt = signoff.SignedAt + }) + .ToListAsync(cancellationToken); + } + + public async Task GetChecklistItemForDispatchAsync( + int itemId, int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchChecklistItems + .FirstOrDefaultAsync( + item => item.Id == itemId && item.DispatchId == dispatchId, + cancellationToken); + } + + public async Task StageAuditLogAsync( + WorkOrderAuditLog log, CancellationToken cancellationToken) + { + await _context.WorkOrderAuditLogs.AddAsync(log, cancellationToken); + } + + public async Task GetDispatchDispatcherUserIdAsync( + int workOrderId, CancellationToken cancellationToken) + { + return await _context.WorkOrderAuditLogs + .AsNoTracking() + .Where(audit => audit.WorkOrderId == workOrderId + && audit.Action == "dispatch" + && audit.UserId != null + && audit.UserId != "") + .OrderByDescending(audit => audit.CreatedAt) + .Select(audit => audit.UserId) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task GetDispatchDetailAsync(int id) + { + return await _context.Dispatches + .AsNoTracking() + .Where(dispatch => dispatch.Id == id) + .Select(dispatch => new DispatchDetailReadModel + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + NTEAmount = dispatch.NTEAmount, + Description = dispatch.Description, + Status = dispatch.Status, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + EmailSent = dispatch.EmailSent, + WorkOrderId = dispatch.WorkOrderId, + AcknowledgedAt = dispatch.AcknowledgedAt, + VerifiedBy = dispatch.VerifiedBy, + VerifiedAt = dispatch.VerifiedAt, + WoNumber = dispatch.WorkOrder != null + ? dispatch.WorkOrder.InternalWONumber + : "", + WoTitle = dispatch.WorkOrder != null + ? dispatch.WorkOrder.WorkerOrderTitle + : "", + VendorName = dispatch.Vendor != null ? dispatch.Vendor.CompanyName : "", + VendorEmail = dispatch.Vendor != null ? dispatch.Vendor.Email : "", + VendorId = dispatch.VendorId, + WorkOrders = _context.DispatchWorkOrders + .Where(link => link.DispatchId == dispatch.Id) + .Select(link => new DispatchWorkOrderReadModel + { + WorkOrderId = link.WorkOrderId, + WoNumber = link.WorkOrder != null + ? link.WorkOrder.InternalWONumber + : "", + WoTitle = link.WorkOrder != null + ? link.WorkOrder.WorkerOrderTitle + : "" + }).ToList(), + ChecklistItems = _context.DispatchChecklistItems + .Where(item => item.DispatchId == dispatch.Id) + .OrderBy(item => item.SortOrder) + .Select(item => new ChecklistItemReadModel + { + Id = item.Id, + ItemText = item.ItemText, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt, + SortOrder = item.SortOrder, + WorkOrderId = item.WorkOrderId + }).ToList(), + Signoffs = _context.DispatchSignoffs + .Where(signoff => signoff.DispatchId == dispatch.Id) + .Select(signoff => new SignoffReadModel + { + Id = signoff.Id, + SignoffType = signoff.SignoffType, + Name = signoff.Name, + Signature = signoff.Signature, + SignatureMethod = signoff.SignatureMethod, + SignedAt = signoff.SignedAt + }).ToList(), + Comments = _context.Comments + .Where(comment => comment.DispatchId == dispatch.Id) + .OrderBy(comment => comment.CreatedDate) + .Select(comment => new DispatchCommentReadModel + { + Id = comment.Id, + CreatedDate = comment.CreatedDate, + Commenttext = comment.Commenttext, + Commenter = comment.Commenter, + CommentType = comment.CommentType, + UserName = comment.ApplicationUser != null + ? comment.ApplicationUser.FirstName + " " + + comment.ApplicationUser.LastName + : "" + }).ToList() + }) + .FirstOrDefaultAsync(); + } } } diff --git a/SeaHaven.DataServices/Implementation/DropdownOptionsDataService.cs b/SeaHaven.DataServices/Implementation/DropdownOptionsDataService.cs new file mode 100644 index 0000000..593dd8b --- /dev/null +++ b/SeaHaven.DataServices/Implementation/DropdownOptionsDataService.cs @@ -0,0 +1,71 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class DropdownOptionsDataService : IDropdownOptionsDataService + { + private readonly ApplicationDbContext _context; + + public DropdownOptionsDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetAllActiveAsync(string? category, CancellationToken cancellationToken) + { + var query = _context.DropdownOptions.Where(d => d.IsActive); + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(d => d.Category == category); + + return await query + .AsNoTracking() + .OrderBy(d => d.Category).ThenBy(d => d.SortOrder).ThenBy(d => d.Value) + .ToListAsync(cancellationToken); + } + + public async Task> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken) + { + var query = _context.DropdownOptions.Where(d => d.IsActive && d.Category == category); + if (!string.IsNullOrWhiteSpace(parent)) + query = query.Where(d => d.ParentValue == parent); + + return await query + .AsNoTracking() + .OrderBy(d => d.SortOrder).ThenBy(d => d.Value) + .ToListAsync(cancellationToken); + } + + public async Task ExistsAsync(string category, string value, string? parentValue, CancellationToken cancellationToken) + { + return await _context.DropdownOptions + .AsNoTracking() + .AnyAsync(d => d.Category == category && d.Value == value && d.ParentValue == parentValue, cancellationToken); + } + + public async Task AddAsync(DropdownOption option, CancellationToken cancellationToken) + { + await _context.DropdownOptions.AddAsync(option, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return option; + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.DropdownOptions.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task UpdateAsync(DropdownOption option, CancellationToken cancellationToken) + { + _context.DropdownOptions.Update(option); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task RemoveAsync(DropdownOption option, CancellationToken cancellationToken) + { + _context.DropdownOptions.Remove(option); + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/FollowUpDataService.cs b/SeaHaven.DataServices/Implementation/FollowUpDataService.cs index 4f34528..4eebe82 100644 --- a/SeaHaven.DataServices/Implementation/FollowUpDataService.cs +++ b/SeaHaven.DataServices/Implementation/FollowUpDataService.cs @@ -120,5 +120,138 @@ namespace SeaHaven.DataServices.Implementation { return await _context.FollowUps.CountAsync(); } + + public async Task> GetListPagedAsync( + int page, + int pageSize, + string? search, + string? status, + CancellationToken cancellationToken) + { + search ??= ""; + status = string.IsNullOrWhiteSpace(status) ? "Open" : status; + + var query = _context.FollowUps + .Include(x => x.Employee) + .Include(x => x.Account) + .Include(x => x.Location) + .AsNoTracking() + .Where(x => x.Status == status); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.ToLower(); + query = query.Where(x => + (x.Reason ?? "").ToLower().Contains(s) || + (x.Account != null && (x.Account.Name ?? "").ToLower().Contains(s)) || + (x.Employee != null && + (((x.Employee.FirstName ?? "") + " " + (x.Employee.LastName ?? "")) + .ToLower().Contains(s)) + ) + ); + } + + return await query + .OrderByDescending(f => f.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + } + + public async Task GetListTotalAsync(string? search, string? status, CancellationToken cancellationToken) + { + search ??= ""; + status = string.IsNullOrWhiteSpace(status) ? "Open" : status; + + var query = _context.FollowUps + .AsNoTracking() + .Where(x => x.Status == status); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.ToLower(); + query = query.Where(x => + (x.Reason ?? "").ToLower().Contains(s) || + (x.Account != null && (x.Account.Name ?? "").ToLower().Contains(s)) || + (x.Employee != null && + (((x.Employee.FirstName ?? "") + " " + (x.Employee.LastName ?? "")) + .ToLower().Contains(s)) + ) + ); + } + + return await query.CountAsync(cancellationToken); + } + + public async Task GetDetailByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.FollowUps + .Include(x => x.Employee) + .Include(x => x.Account) + .Include(x => x.Location) + .AsNoTracking() + .FirstOrDefaultAsync(f => f.Id == id, cancellationToken); + } + + public async Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken) + { + return await _context.FollowUps.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task GetUserNameAsync(string? userId, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(userId)) + return ""; + + var u = await _context.Users + .AsNoTracking() + .Where(x => x.Id == userId) + .Select(x => new { x.FirstName, x.LastName, x.UserName }) + .FirstOrDefaultAsync(cancellationToken); + + if (u == null) + return ""; + + var n = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim(); + return string.IsNullOrWhiteSpace(n) ? (u.UserName ?? "") : n; + } + + public async Task AddAsync(FollowUps followUp, CancellationToken cancellationToken) + { + followUp.CreatedDate = DateTime.Now; + await _context.FollowUps.AddAsync(followUp, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return followUp; + } + + public async Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken) + { + followUp.LastModificationTime = DateTime.Now; + _context.FollowUps.Update(followUp); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task SetStatusAsync(int id, string status, CancellationToken cancellationToken) + { + var entity = await _context.FollowUps.FindAsync(new object[] { id }, cancellationToken); + if (entity == null) + return false; + + entity.Status = status; + entity.LastModificationTime = DateTime.Now; + await _context.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task RemoveByIdAsync(int id, CancellationToken cancellationToken) + { + var entity = await _context.FollowUps.FindAsync(new object[] { id }, cancellationToken); + if (entity == null) + return false; + + _context.FollowUps.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } } } diff --git a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs new file mode 100644 index 0000000..c1781a2 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs @@ -0,0 +1,83 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class ForgetPasswordDataService : IForgetPasswordDataService + { + private readonly ApplicationDbContext _context; + + public ForgetPasswordDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken) + { + var normalizedEmail = email.ToLower().Trim(); + + var existing = await _context.ForgetPasswordCodes + .Where(u => u.Email.ToLower().Trim() == normalizedEmail) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + _context.ForgetPasswordCodes.Remove(existing); + await _context.SaveChangesAsync(cancellationToken); + } + + var forgetPasswordCode = new ForgetPasswordCode + { + Email = email, + UserId = userId, + Code = code + }; + + _context.ForgetPasswordCodes.Add(forgetPasswordCode); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task CodeExistsAsync(string code, CancellationToken cancellationToken) + { + return await _context.ForgetPasswordCodes + .AsNoTracking() + .Where(u => u.Code == code) + .AnyAsync(cancellationToken); + } + + public async Task ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken) + { + var normalizedEmail = email.ToLower().Trim(); + var trimmedCode = code.Trim(); + + return await _context.ForgetPasswordCodes + .AsNoTracking() + .Where(u => u.Email.ToLower().Trim() == normalizedEmail && u.Code == trimmedCode) + .AnyAsync(cancellationToken); + } + + public async Task GetByEmailAsync(string email, CancellationToken cancellationToken) + { + var normalizedEmail = email.ToLower().Trim(); + + return await _context.ForgetPasswordCodes + .Where(u => u.Email.ToLower().Trim() == normalizedEmail) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) + { + var normalizedEmail = email.ToLower().Trim(); + var records = await _context.ForgetPasswordCodes + .Where(code => code.Email.ToLower().Trim() == normalizedEmail) + .ToListAsync(cancellationToken); + + if (records.Count == 0) + return; + + _context.ForgetPasswordCodes.RemoveRange(records); + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index b48cd5b..7ccb6b8 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -37,16 +37,16 @@ namespace SeaHaven.DataServices.Implementation } public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( - int page, - int pageSize, + int page, + int pageSize, string? search = null) { var query = _context.Locations.AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) { - query = query.Where(l => - l.Name!.Contains(search) || + query = query.Where(l => + l.Name!.Contains(search) || l.City!.Contains(search)); } @@ -113,6 +113,69 @@ namespace SeaHaven.DataServices.Implementation return await _context.Locations.CountAsync(); } + public async Task<(List Items, int TotalCount)> GetListPagedAsync( + int page, + int pageSize, + string? search, + CancellationToken cancellationToken) + { + var query = _context.Locations.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(search)) + { + query = query.Where(l => + l.Name!.Contains(search) || + l.City!.Contains(search)); + } + + var totalCount = await query.CountAsync(cancellationToken); + var items = await query + .OrderBy(l => l.Name) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, totalCount); + } + + public async Task GetDetailByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.Locations + .AsNoTracking() + .FirstOrDefaultAsync(l => l.Id == id, cancellationToken); + } + + public async Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken) + { + return await _context.Locations.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task AddAsync(Locations location, CancellationToken cancellationToken) + { + location.CreatedDate = DateTime.Now; + await _context.Locations.AddAsync(location, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return location; + } + + public async Task UpdateAsync(Locations location, CancellationToken cancellationToken) + { + location.LastModificationTime = DateTime.Now; + _context.Locations.Update(location); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteByIdAsync(int id, CancellationToken cancellationToken) + { + var entity = await _context.Locations.FindAsync(new object[] { id }, cancellationToken); + if (entity == null) + return false; + + _context.Locations.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + return true; + } + public async Task<(IEnumerable Items, int TotalCount)> GetAddressbookPagedAsync( int page, int pageSize, diff --git a/SeaHaven.DataServices/Implementation/QuotesDataService.cs b/SeaHaven.DataServices/Implementation/QuotesDataService.cs index 44fc3f5..8dd5358 100644 --- a/SeaHaven.DataServices/Implementation/QuotesDataService.cs +++ b/SeaHaven.DataServices/Implementation/QuotesDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -71,5 +72,108 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(); } + + public async Task<(IReadOnlyList Items, int TotalCount)> GetQuotesPagedAsync( + int page, int pageSize, string search, CancellationToken cancellationToken) + { + var normalizedSearch = search.Trim(); + var query = _context.Quotes + .AsNoTracking() + .Where(w => normalizedSearch.Length == 0 || + (w.QuotId != null && w.QuotId.Contains(normalizedSearch)) || + (w.SiteName != null && w.SiteName.Contains(normalizedSearch)) || + (w.WorkOrder != null && w.WorkOrder.WorkerOrderTitle != null && + w.WorkOrder.WorkerOrderTitle.Contains(normalizedSearch))); + + var totalCount = await query.CountAsync(cancellationToken); + var data = await query + .OrderByDescending(s => s.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(s => new QuoteListItemReadModel + { + Id = s.Id, + QuoteId = "QO-" + s.QuotId, + WorkOrder = s.WorkOrder != null ? s.WorkOrder.WorkerOrderNumber : "", + ProjectName = s.ProjectName, + TotalAmount = s.LineItems!.Sum(l => l.Lineitem ?? 0), + SiteName = s.SiteName, + SiteId = s.SiteId, + PO = s.PO, + TT = s.TT, + BillingAddress = s.BillingAddress, + Status = s.Status, + ContactId = s.ContactId, + Date = s.Date, + Lineitems = s.LineItems!.Select(q => new QuoteLineItemReadModel + { + Descriptions = q.Descriptions, + Lineitem = q.Lineitem, + LineitemNumber = q.LineitemNumber + }).ToList(), + }) + .ToListAsync(cancellationToken); + + return (data, totalCount); + } + + public async Task GetQuoteDetailsByIdAsync( + int id, CancellationToken cancellationToken) + { + return await _context.Quotes + .Where(s => s.Id == id) + .AsNoTracking() + .Select(s => new QuoteDetailReadModel + { + Id = s.Id, + QuotId = s.QuotId, + Date = s.Date, + Name = s.Contacts != null ? (s.Contacts.FirstName + " " + s.Contacts.LastName).Trim() : "", + PO = s.PO, + TT = s.TT, + SiteName = s.SiteName, + ProjectName = s.ProjectName, + SiteId = s.SiteId, + BillingAddress = s.BillingAddress, + ShippingAddress = s.ShippingAddress, + Lineitems = s.LineItems!.Select(q => new QuoteLineItemReadModel + { + Descriptions = q.Descriptions, + Lineitem = q.Lineitem + }).ToList(), + TotalAmount = s.LineItems!.Sum(l => l.Lineitem ?? 0) + }) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken) + { + return await _context.workOrders + .AsNoTracking() + .Where(w => w.Id == woId) + .Select(s => new WorkOrderQuoteDetailReadModel + { + Id = s.Id, + PO = s.PO, + TT = s.TT, + Title = s.Locations != null ? s.Locations.Title : "", + Name = s.Locations != null ? s.Locations.Name : "" + }) + .ToListAsync(cancellationToken); + } + + public async Task DeleteLineItemsByQuoteIdAsync(int quoteId, CancellationToken cancellationToken) + { + var items = await _context.QuotesLines + .Where(w => w.QuoteId == quoteId) + .ToListAsync(cancellationToken); + + if (items.Any()) + { + _context.QuotesLines.RemoveRange(items); + await _context.SaveChangesAsync(cancellationToken); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/SettingsDataService.cs b/SeaHaven.DataServices/Implementation/SettingsDataService.cs new file mode 100644 index 0000000..f0a5feb --- /dev/null +++ b/SeaHaven.DataServices/Implementation/SettingsDataService.cs @@ -0,0 +1,125 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class SettingsDataService : ISettingsDataService + { + private readonly ApplicationDbContext _context; + + public SettingsDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task AddCategoryAsync(string name, CancellationToken cancellationToken) + { + var category = new Category { Name = name }; + await _context.Categories.AddAsync(category, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateCategoryAsync(int id, string name, CancellationToken cancellationToken) + { + var exist = await _context.Categories + .Where(w => w.Id == id) + .FirstOrDefaultAsync(cancellationToken); + if (exist == null) + return false; + + exist.Name = name; + _context.Categories.Update(exist); + await _context.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task DeleteCategoryCascadeAsync(int id, CancellationToken cancellationToken) + { + var exist = await _context.Categories + .Where(w => w.Id == id) + .FirstOrDefaultAsync(cancellationToken); + if (exist == null) + return false; + + var templates = await _context.Templates.Where(w => w.CategoryId == id).ToListAsync(cancellationToken); + if (templates != null) + { + _context.RemoveRange(templates); + } + + var workOrderCategories = await _context.workOrderCategories.Where(w => w.CategoryId == id).ToListAsync(cancellationToken); + if (workOrderCategories != null) + { + _context.RemoveRange(workOrderCategories); + } + + _context.Remove(exist); + await _context.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task<(IReadOnlyList Items, int Total)> GetCategoryListPagedAsync( + int page, + int pageSize, + CancellationToken cancellationToken) + { + var totalCount = await _context.Categories.CountAsync(cancellationToken); + var data = await _context.Categories + .AsNoTracking() + .Select(s => new SettingsCategoryItemDTO + { + Id = s.Id, + Name = s.Name + }) + .OrderByDescending(d => d.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (data, totalCount); + } + + public async Task<(IReadOnlyList Items, int Total)> GetTemplatesPagedAsync( + int page, + int pageSize, + CancellationToken cancellationToken) + { + var totalCount = await _context.workOrders.CountAsync(w => w.istemplate == true, cancellationToken); + var data = await _context.workOrders + .AsNoTracking() + .Where(w => w.istemplate == true) + .OrderByDescending(d => d.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (data, totalCount); + } + + public async Task<(IReadOnlyList Users, int Total)> GetUsersPagedAsync( + string? search, + int page, + int pageSize, + CancellationToken cancellationToken) + { + var totalCount = await _context.Users.CountAsync(cancellationToken); + var users = await _context.Users + .AsNoTracking() + .Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower())) + .OrderByDescending(d => d.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (users, totalCount); + } + + public async Task> GetRoleNamesAsync(CancellationToken cancellationToken) + { + var roles = await _context.Roles.AsNoTracking().ToListAsync(cancellationToken); + return roles.Select(r => r.Name!).ToList(); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/SyncDataService.cs b/SeaHaven.DataServices/Implementation/SyncDataService.cs new file mode 100644 index 0000000..3ca083c --- /dev/null +++ b/SeaHaven.DataServices/Implementation/SyncDataService.cs @@ -0,0 +1,127 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class SyncDataService : ISyncDataService + { + private readonly ApplicationDbContext _context; + + public SyncDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetLastInternalWONumberAsync(CancellationToken cancellationToken) + { + return await _context.workOrders + .Where(w => w.InternalWONumber != null && w.InternalWONumber != "") + .OrderByDescending(w => w.InternalWONumber) + .Select(w => w.InternalWONumber) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetWorkOrderByExternalIdAsync(string externalId, CancellationToken cancellationToken) + { + return await _context.workOrders + .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId, cancellationToken); + } + + public void EnqueueWorkOrder(WorkOrder workOrder) + { + _context.workOrders.Add(workOrder); + } + + public void EnqueueComment(Comments comment) + { + _context.Comments.Add(comment); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task ResolveLocationAsync(string? siteCode, string? building, string? address, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building)) + return null; + + var matchCode = siteCode ?? building; + var existing = await _context.Locations + .FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken); + + if (existing != null) + return existing.Id; + + var location = new Locations + { + Name = matchCode, + Title = building, + Address1 = address, + Status = "Active" + }; + + _context.Locations.Add(location); + await _context.SaveChangesAsync(cancellationToken); + + return location.Id; + } + + public async Task GetLocationCountAsync(CancellationToken cancellationToken) + { + return await _context.Locations.CountAsync(cancellationToken); + } + + public async Task CommentExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken) + { + return await _context.Comments + .AnyAsync(c => c.ExternalCommentId == externalId, cancellationToken); + } + + public async Task GetWorkOrderByInternalNumberAsync(string internalNumber, CancellationToken cancellationToken) + { + return await _context.workOrders + .FirstOrDefaultAsync(w => w.InternalWONumber == internalNumber, cancellationToken); + } + + public async Task GetDispatchByNumberAndWorkOrderIdAsync(string dispatchNumber, int workOrderId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrderId, cancellationToken); + } + + public async Task> GetWorkOrdersMissingInternalNumberAsync(CancellationToken cancellationToken) + { + return await _context.workOrders + .Where(w => w.InternalWONumber == null || w.InternalWONumber == "") + .OrderBy(w => w.Id) + .ToListAsync(cancellationToken); + } + + public async Task> GetDispatchesMissingNumberAsync(CancellationToken cancellationToken) + { + return await _context.Dispatches + .Where(d => d.DispatchNumber == null || d.DispatchNumber == "") + .OrderBy(d => d.Id) + .ToListAsync(cancellationToken); + } + + public async Task ExecuteBackfillCommentTypesAsync(CancellationToken cancellationToken) + { + return await _context.Database.ExecuteSqlRawAsync( + "UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')", + cancellationToken); + } + + public async Task GetLastDispatchNumberAsync(CancellationToken cancellationToken) + { + return await _context.Dispatches + .Where(d => d.DispatchNumber != null && d.DispatchNumber != "") + .OrderByDescending(d => d.DispatchNumber) + .Select(d => d.DispatchNumber) + .FirstOrDefaultAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/TaskListTemplateDataService.cs b/SeaHaven.DataServices/Implementation/TaskListTemplateDataService.cs index f0ce1af..a5ec17d 100644 --- a/SeaHaven.DataServices/Implementation/TaskListTemplateDataService.cs +++ b/SeaHaven.DataServices/Implementation/TaskListTemplateDataService.cs @@ -57,5 +57,58 @@ namespace SeaHaven.DataServices.Implementation { return await _context.TaskListTemplates.AnyAsync(t => t.Id == id); } + + public async Task> GetActiveTemplatesWithItemsAsync(CancellationToken cancellationToken) + { + return await _context.TaskListTemplates + .Include(t => t.Items) + .Where(t => t.IsActive) + .OrderBy(t => t.Name) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task GetTemplateWithItemsReadOnlyAsync(int id, CancellationToken cancellationToken) + { + return await _context.TaskListTemplates + .Include(t => t.Items) + .Where(t => t.Id == id) + .AsNoTracking() + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetTemplateWithItemsForUpdateAsync(int id, CancellationToken cancellationToken) + { + return await _context.TaskListTemplates + .Include(t => t.Items) + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + } + + public async Task AddTemplateAsync(TaskListTemplate template, CancellationToken cancellationToken) + { + await _context.TaskListTemplates.AddAsync(template, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return template; + } + + public async Task ReplaceTemplateItemsAsync(TaskListTemplate template, IEnumerable newItems, CancellationToken cancellationToken) + { + if (template.Items != null) + _context.TaskListTemplateItems.RemoveRange(template.Items); + + template.Items = newItems.ToList(); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeactivateTemplateAsync(int id, CancellationToken cancellationToken) + { + var template = await _context.TaskListTemplates.FindAsync(new object[] { id }, cancellationToken); + if (template == null) + return false; + + template.IsActive = false; + await _context.SaveChangesAsync(cancellationToken); + return true; + } } } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs new file mode 100644 index 0000000..f7000c2 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -0,0 +1,138 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class UpliftDataService : IUpliftDataService + { + private readonly ApplicationDbContext _context; + + public UpliftDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( + string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) + { + var query = from u in _context.DispatchUpliftRequests + join d in _context.Dispatches on u.DispatchId equals d.Id + join v in _context.Vendors on d.VendorId equals v.Id into vendors + from v in vendors.DefaultIfEmpty() + where (u.IsDeleted == null || u.IsDeleted == false) + select new { u, d, v }; + + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(x => x.u.Status == status); + if (tier.HasValue) + query = query.Where(x => x.u.RequiredTier == tier.Value); + + var total = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(x => x.u.CreatedDate) + .Skip((Math.Max(page, 1) - 1) * pageSize) + .Take(pageSize) + .Select(x => new UpliftListItemData + { + Id = x.u.Id, + DispatchId = x.d.Id, + DispatchNumber = x.d.DispatchNumber, + PONumber = x.d.PONumber, + WorkOrderId = x.d.WorkOrderId, + VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, + CurrentNTE = x.u.CurrentNTE, + RequestedNTE = x.u.RequestedNTE, + VendorReason = x.u.VendorReason, + RequiredTier = x.u.RequiredTier, + Status = x.u.Status, + CreatedDate = x.u.CreatedDate, + DecidedAt = x.u.DecidedAt, + DecisionNote = x.u.DecisionNote + }) + .ToListAsync(cancellationToken); + + return (total, items); + } + + public async Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from u in _context.DispatchUpliftRequests + where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) + join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs + from dec in decs.DefaultIfEmpty() + orderby u.CreatedDate descending + select new UpliftForDispatchData + { + Id = u.Id, + DispatchId = u.DispatchId, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + CreatedDate = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByFirstName = dec != null ? dec.FirstName : null, + DecidedByLastName = dec != null ? dec.LastName : null + }).ToListAsync(cancellationToken); + } + + public async Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from u in _context.DispatchUpliftRequests + where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) + join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs + from dec in decs.DefaultIfEmpty() + orderby u.CreatedDate descending + select new PortalUpliftData + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + CreatedDate = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByFirstName = dec != null ? dec.FirstName : null, + DecidedByLastName = dec != null ? dec.LastName : null + }).ToListAsync(cancellationToken); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == dispatchId, cancellationToken); + } + + public async Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .AnyAsync(u => u.DispatchId == dispatchId + && u.Status == "Pending" + && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); + } + + public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken) + { + await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 436a806..6ad82cc 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -34,6 +35,123 @@ namespace SeaHaven.DataServices.Implementation return await _context.Users.AnyAsync(u => u.Id == id); } + public async Task> GetAllForListAsync(CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Select(user => new UserListRowData + { + Id = user.Id, + RoleName = user.PhoneNumber, + Email = user.Email, + Name = user.FirstName, + CreatedDate = user.CreatedDate, + Status = user.UniqueName + }) + .ToListAsync(cancellationToken); + } + + public async Task GetProfileAsync(string id, CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Where(u => u.Id == id) + .Select(user => new UserProfileData + { + Id = user.Id, + FirstName = user.FirstName, + Email = user.Email, + Contact = user.Contact, + CreatedDate = user.CreatedDate + }) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task UpdateProfileAsync( + string id, + string? name, + string? email, + string? contact, + CancellationToken cancellationToken) + { + var updated = await _context.Users + .Where(user => user.Id == id) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(user => user.FirstName, name) + .SetProperty(user => user.Email, email) + .SetProperty(user => user.Contact, contact), + cancellationToken); + + return updated == 1; + } + + public async Task GetForEditAsync(string id, CancellationToken cancellationToken) + { + return await _context.Users + .Where(w => w.Id == id) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken) + { + var normalizedEmail = email.Trim().ToUpperInvariant(); + return await _context.Users + .AsNoTracking() + .Where(user => user.NormalizedEmail == normalizedEmail) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken) + { + _context.Users.Update(user); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken) + { + var id = user.Id; + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + + await _context.workOrders + .Where(workOrder => workOrder.AssignTo == id) + .ExecuteUpdateAsync( + setters => setters.SetProperty(workOrder => workOrder.AssignTo, (string?)null), + cancellationToken); + + await _context.Comments + .Where(comment => comment.UserId == id) + .ExecuteUpdateAsync( + setters => setters.SetProperty(comment => comment.UserId, (string?)null), + cancellationToken); + + await _context.Templates + .Where(template => template.AssignTo == id) + .ExecuteUpdateAsync( + setters => setters.SetProperty(template => template.AssignTo, (string?)null), + cancellationToken); + + await _context.UserRoles + .Where(role => role.UserId == id) + .ExecuteDeleteAsync(cancellationToken); + + await _context.Users + .Where(existingUser => existingUser.Id == id) + .ExecuteDeleteAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + + public async Task GetEmailByIdAsync( + string userId, CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Where(user => user.Id == userId) + .Select(user => user.Email) + .FirstOrDefaultAsync(cancellationToken); + } + public async Task> GetDisplayNamesByIdsAsync(IEnumerable ids) { var idList = ids diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 541c89f..d6767dc 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -29,11 +29,56 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(); } + public async Task> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken) + { + var orderedIds = ids.Distinct().ToList(); + if (orderedIds.Count == 0) + return Array.Empty(); + + var loaded = await _context.Vendors + .AsNoTracking() + .Where(v => orderedIds.Contains(v.Id)) + .ToListAsync(cancellationToken); + + var byId = loaded.ToDictionary(v => v.Id); + var ordered = new List(orderedIds.Count); + foreach (var id in orderedIds) + { + if (byId.TryGetValue(id, out var vendor)) + ordered.Add(vendor); + } + + return ordered; + } + public async Task> GetAllAsync() { return await _context.Vendors.ToListAsync(); } + public async Task> GetActiveVendorsAsync(CancellationToken cancellationToken) + { + return await _context.Vendors + .AsNoTracking() + .Where(v => v.IsActive) + .OrderBy(v => v.CompanyName) + .ToListAsync(cancellationToken); + } + + public async Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken) + { + var query = _context.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); + + return await query + .OrderByDescending(v => v.Id) + .ToListAsync(cancellationToken); + } + public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, @@ -284,16 +329,47 @@ namespace SeaHaven.DataServices.Implementation return await _context.Vendors.AnyAsync(v => v.Id == id); } - public async Task GetActiveTokenAsync(int vendorId) + public async Task GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken) { + var now = DateTime.UtcNow; return await _context.VendorAccessTokens - .FirstOrDefaultAsync(t => t.VendorId == vendorId); + .AsNoTracking() + .Where(t => t.VendorId == vendorId + && t.RevokedAt == null + && t.ExpiresAt > now + && (t.IsDeleted == null || t.IsDeleted == false)) + .OrderByDescending(t => t.IssuedAt) + .FirstOrDefaultAsync(cancellationToken); } - public async Task GetTokenByStringAsync(string token) + public async Task GetTokenWithVendorAsync(string token, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + return await _context.VendorAccessTokens + .Include(t => t.Vendor) + .FirstOrDefaultAsync(t => t.Token == token + && t.RevokedAt == null + && t.ExpiresAt > now + && (t.IsDeleted == null || t.IsDeleted == false), cancellationToken); + } + + public async Task AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken) + { + await _context.VendorAccessTokens.AddAsync(token, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return token; + } + + public async Task> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken) { return await _context.VendorAccessTokens - .FirstOrDefaultAsync(t => t.Token == token); + .Where(t => t.VendorId == vendorId && t.RevokedAt == null) + .ToListAsync(cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); } public async Task CountAsync() diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs new file mode 100644 index 0000000..2ee5192 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -0,0 +1,43 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class VendorDocumentDataService : IVendorDocumentDataService + { + private readonly ApplicationDbContext _context; + + public VendorDocumentDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return _context.VendorCompletionDocuments + .Where(document => document.DispatchId == dispatchId) + .OrderByDescending(document => document.Version) + .FirstOrDefaultAsync(cancellationToken); + } + + public Task GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken) + { + return _context.VendorCompletionDocuments + .FirstOrDefaultAsync(document => document.Id == documentId + && document.DispatchId == dispatchId + && document.VendorId == vendorId + && (document.IsDeleted == null || document.IsDeleted == false), cancellationToken); + } + + public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken) + { + await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken) + { + return _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs new file mode 100644 index 0000000..e1eefc3 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs @@ -0,0 +1,231 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; + +namespace SeaHaven.DataServices.Implementation +{ + public class VendorOperationsDataService : IVendorOperationsDataService + { + private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" }; + + private readonly ApplicationDbContext _context; + private readonly IDispatchDataService _dispatchDataService; + + public VendorOperationsDataService(ApplicationDbContext context, IDispatchDataService dispatchDataService) + { + _context = context; + _dispatchDataService = dispatchDataService; + } + + public async Task> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken) + { + var directlyAssignedIds = await _context.Dispatches + .AsNoTracking() + .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue) + .Select(d => d.WorkOrderId!.Value) + .ToListAsync(cancellationToken); + var multiplyAssignedIds = await _context.DispatchWorkOrders + .AsNoTracking() + .Where(link => link.Dispatch != null + && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) + .Select(link => link.WorkOrderId) + .ToListAsync(cancellationToken); + return directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList(); + } + + public async Task> GetNoVendorWorkOrdersAsync( + DateTime cutoff, IReadOnlyCollection excludeIds, CancellationToken cancellationToken) + { + return await _context.workOrders + .AsNoTracking() + .Where(wo => wo.ScheduledDate.HasValue + && wo.ScheduledDate.Value <= cutoff + && wo.LifecycleStatus != LifecycleStatus.Completed + && wo.LifecycleStatus != LifecycleStatus.Canceled + && !excludeIds.Contains(wo.Id)) + .OrderBy(wo => wo.ScheduledDate) + .Select(wo => new NoVendorCandidateReadModel + { + Id = wo.Id, + InternalWONumber = wo.InternalWONumber, + WorkerOrderNumber = wo.WorkerOrderNumber, + ScheduledDate = wo.ScheduledDate + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken) + { + var direct = _context.Dispatches.AsNoTracking() + .Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "") + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false) + && dispatch.WorkOrderId.HasValue + && (dispatch.ScheduledDate.HasValue + || (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue))) + .Select(dispatch => new ScheduledDispatchReadModel + { + Id = dispatch.Id, + VendorId = dispatch.VendorId, + VendorName = dispatch.Vendor!.CompanyName, + WorkOrderId = dispatch.WorkOrderId!.Value, + WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber, + Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value, + End = dispatch.WorkOrder.ScheduledEnd + ?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2) + }); + + var linked = _context.DispatchWorkOrders.AsNoTracking() + .Where(link => link.Dispatch != null + && link.WorkOrder != null + && link.Dispatch.WorkOrderId != link.WorkOrderId + && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "") + && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) + && (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue)) + .Select(link => new ScheduledDispatchReadModel + { + Id = link.DispatchId, + VendorId = link.Dispatch!.VendorId, + VendorName = link.Dispatch.Vendor!.CompanyName, + WorkOrderId = link.WorkOrderId, + WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber, + Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value, + End = link.WorkOrder.ScheduledEnd + ?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2) + }); + + return await direct.Concat(linked).ToListAsync(cancellationToken); + } + + public async Task> GetActiveVendorsAsync(CancellationToken cancellationToken) + { + return await _context.Vendors.AsNoTracking() + .Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false)) + .Select(v => new ActiveVendorReadModel + { + Id = v.Id, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + TradeSpecialties = v.TradeSpecialties, + AvailabilityStatus = v.AvailabilityStatus, + AvailabilityUpdatedAt = v.AvailabilityUpdatedAt + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken) + { + return await _context.SitePreferredVendors + .AsNoTracking() + .Where(p => p.LocationId == locationId) + .Include(p => p.Vendor) + .OrderBy(p => p.SortOrder) + .ToListAsync(cancellationToken); + } + + public async Task> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken) + { + return await _context.SitePreferredVendors + .Where(p => p.LocationId == locationId) + .ToListAsync(cancellationToken); + } + + public Task LocationExistsAsync(int locationId, CancellationToken cancellationToken) + => _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken); + + public async Task> GetVendorsByIdsAsync(IReadOnlyCollection vendorIds, CancellationToken cancellationToken) + => await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)) + .ToDictionaryAsync(vendor => vendor.Id, cancellationToken); + + public async Task> GetInsightDispatchDataAsync( + DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken) + { + var query = _context.Dispatches.AsNoTracking() + .Include(dispatch => dispatch.Vendor) + .Include(dispatch => dispatch.WorkOrder) + .Where(dispatch => dispatch.Vendor != null); + if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from); + if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to); + if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId); + if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade); + if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId); + + return await query.Select(dispatch => new InsightDispatchData + { + VendorId = dispatch.VendorId, + VendorName = dispatch.Vendor!.CompanyName, + Status = dispatch.Status, + DispatchedAt = dispatch.DispatchedAt, + CreatedDate = dispatch.CreatedDate, + ScheduledDate = dispatch.ScheduledDate, + AcknowledgedAt = dispatch.AcknowledgedAt, + CompletedDate = dispatch.CompletedDate, + DueDate = dispatch.WorkOrder != null ? dispatch.WorkOrder.DueDate : null + }).ToListAsync(cancellationToken); + } + + public Task GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken) + => _context.Vendors.FirstOrDefaultAsync(v => v.Id == vendorId, cancellationToken); + + public Task GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken) + => _context.VendorCompletionDocuments.FirstOrDefaultAsync(d => d.Id == documentId, cancellationToken); + + public Task GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken) + => _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken); + + public Task GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + + public async Task GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + { + var linkedDispatchIds = _context.DispatchWorkOrders + .Where(link => link.WorkOrderId == workOrderId) + .Select(link => link.DispatchId); + return await _context.Dispatches + .Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id)) + && !TerminalDispatchStatuses.Contains(item.Status ?? "")) + .OrderByDescending(item => item.CreatedDate) + .FirstOrDefaultAsync(cancellationToken); + } + + public Task GenerateNextDispatchNumberAsync(CancellationToken cancellationToken) + => _dispatchDataService.GenerateNextDispatchNumberAsync(); + + public Task GenerateNextPONumberAsync(CancellationToken cancellationToken) + => _dispatchDataService.GenerateNextPONumberAsync(); + + public Task AddDispatchAsync(Dispatch dispatch, CancellationToken cancellationToken) + { + _context.Dispatches.Add(dispatch); + return Task.CompletedTask; + } + + public Task StageWorkOrderAuditAsync(WorkOrderAuditLog log, CancellationToken cancellationToken) + { + _context.WorkOrderAuditLogs.Add(log); + return Task.CompletedTask; + } + + public Task StageVendorAuditAsync(VendorAuditLog log, CancellationToken cancellationToken) + { + _context.VendorAuditLogs.Add(log); + return Task.CompletedTask; + } + + public Task AddSitePreferredVendorAsync(SitePreferredVendor preference, CancellationToken cancellationToken) + { + _context.SitePreferredVendors.Add(preference); + return Task.CompletedTask; + } + + public Task RemoveSitePreferredVendorsAsync(IEnumerable preferences, CancellationToken cancellationToken) + { + _context.SitePreferredVendors.RemoveRange(preferences); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken) + => _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs new file mode 100644 index 0000000..c057337 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs @@ -0,0 +1,43 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderAuditDataService : IWorkOrderAuditDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderAuditDataService(ApplicationDbContext context) + { + _context = context; + } + + public void EnqueueAuditLog(WorkOrderAuditLog auditLog) + { + _context.WorkOrderAuditLogs.Add(auditLog); + } + + public async Task PersistAsync(CancellationToken cancellationToken) + { + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) + { + foreach (var entry in ex.Entries.Where(e => e.Entity is WorkOrderFieldLock)) + entry.State = EntityState.Detached; + + await _context.SaveChangesAsync(cancellationToken); + } + } + + private static bool IsUniqueConstraintViolation(DbUpdateException ex) + { + var message = ex.InnerException?.Message ?? ex.Message; + return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) + || message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs new file mode 100644 index 0000000..dbab5d4 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs @@ -0,0 +1,92 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderBoardMutationDataService : IWorkOrderBoardMutationDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderBoardMutationDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task VendorExistsAsync(int vendorId, CancellationToken cancellationToken) + => _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true, cancellationToken); + + public async Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken) + => await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0; + + public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync( + w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null), + cancellationToken); + + public Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken) + => _context.Dispatches.FirstOrDefaultAsync( + d => d.Id == dispatchId && d.WorkOrderId == workOrderId, + cancellationToken); + + public Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken) + => _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken); + + public void TrackNewWorkOrder(WorkOrder workOrder) + => _context.workOrders.Add(workOrder); + + public void TrackNewDispatch(Dispatch dispatch) + => _context.Dispatches.Add(dispatch); + + public void TrackWorkOrderContact(int workOrderId, int contactId, string? notes) + => _context.WorkOrderContacts.Add(new WorkOrderContacts + { + WorkorderId = workOrderId, + ContactId = contactId, + Notes = notes + }); + + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) + => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; + + public void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version) + => _context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = version; + + public bool HasPendingChanges() + => _context.ChangeTracker.HasChanges(); + + public async Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken) + { + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync(cancellationToken) + : null; + + try + { + await work(cancellationToken); + if (transaction is not null) + await transaction.CommitAsync(cancellationToken); + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + public async Task SaveAsync(CancellationToken cancellationToken) + { + try + { + await _context.SaveChangesAsync(cancellationToken); + return BoardSaveOutcome.Saved; + } + catch (DbUpdateConcurrencyException) + { + _context.ChangeTracker.Clear(); + return BoardSaveOutcome.ConcurrencyConflict; + } + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs new file mode 100644 index 0000000..25369bd --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderCompletionDataService.cs @@ -0,0 +1,30 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderCompletionDataService : IWorkOrderCompletionDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderCompletionDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync( + w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null), + cancellationToken); + + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) + => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; + + public Task GetTrackedTemplateAsync(int id, CancellationToken cancellationToken) + => _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken); + + public Task SaveAsync(CancellationToken cancellationToken) + => _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index 9509840..a382d80 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -101,7 +102,7 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } - public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search = null, @@ -131,13 +132,12 @@ namespace SeaHaven.DataServices.Implementation if (!string.IsNullOrWhiteSpace(search)) { - var s = search.ToLower(); query = query.Where(w => - (w.InternalWONumber ?? "").ToLower().Contains(s) || - (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || - (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || - (w.Locations.Title ?? "").ToLower().Contains(s) || - (w.Locations.Name ?? "").ToLower().Contains(s) + (w.InternalWONumber != null && w.InternalWONumber.Contains(search)) || + (w.WorkerOrderNumber != null && w.WorkerOrderNumber.Contains(search)) || + (w.WorkerOrderTitle != null && w.WorkerOrderTitle.Contains(search)) || + (w.Locations != null && w.Locations.Title != null && w.Locations.Title.Contains(search)) || + (w.Locations != null && w.Locations.Name != null && w.Locations.Name.Contains(search)) ); } @@ -160,24 +160,24 @@ namespace SeaHaven.DataServices.Implementation var data = await ordered .Skip((page - 1) * pageSize) .Take(pageSize) - .Select(w => new + .Select(w => new WorkOrderListItemReadModel { - id = w.Id, - internalWONumber = w.InternalWONumber, - workOrderNumber = w.WorkerOrderNumber, - number = w.InternalWONumber ?? w.WorkerOrderNumber, - title = w.WorkerOrderTitle, - location = w.Locations != null ? w.Locations.Name : "", - locationName = w.Locations != null ? w.Locations.Name : "", - priority = w.Priority, - status = w.Status, - createdDate = w.CreatedDate, - dueDate = w.DueDate, - assignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", - employeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", - lastUpdated = new[] { + Id = w.Id, + InternalWONumber = w.InternalWONumber, + WorkOrderNumber = w.WorkerOrderNumber, + Number = w.InternalWONumber ?? w.WorkerOrderNumber, + Title = w.WorkerOrderTitle, + Location = w.Locations != null ? w.Locations.Name : "", + LocationName = w.Locations != null ? w.Locations.Name : "", + Priority = w.Priority, + Status = w.Status, + CreatedDate = w.CreatedDate, + DueDate = w.DueDate, + AssignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", + EmployeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", + LastUpdated = new[] { w.LastModificationTime, - w.Comments.Max(c => (DateTime?)c.CreatedDate), + w.Comments!.Max(c => (DateTime?)c.CreatedDate), _context.WorkOrderAuditLogs.Where(a => a.WorkOrderId == w.Id).Max(a => (DateTime?)a.CreatedAt) }.Where(d => d.HasValue).Max() }) @@ -283,6 +283,15 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(); } + public async Task AddAuditLogsAsync(IReadOnlyCollection auditLogs) + { + if (auditLogs.Count == 0) + return; + + await _context.WorkOrderAuditLogs.AddRangeAsync(auditLogs); + await _context.SaveChangesAsync(); + } + public async Task DeleteWorkOrderContactsAsync(int workOrderId) { var contacts = await _context.WorkOrderContacts @@ -349,6 +358,11 @@ namespace SeaHaven.DataServices.Implementation return await _context.workOrders.CountAsync(w => w.Status == status); } + public async Task CountByPriorityAsync(string priority) + { + return await _context.workOrders.CountAsync(w => w.Priority == priority); + } + public async Task CountByLocationAsync(int locationId) { return await _context.workOrders.CountAsync(w => w.LocationId == locationId); @@ -363,5 +377,146 @@ namespace SeaHaven.DataServices.Implementation .AsSplitQuery() .ToListAsync(); } + + public async Task> GetByRequestedIdsWithDetailsAsync( + IReadOnlyCollection ids, + CancellationToken cancellationToken) + { + var orderedIds = ids.Distinct().ToList(); + if (orderedIds.Count == 0) + return Array.Empty(); + + var loaded = await _context.workOrders + .AsNoTracking() + .Where(w => orderedIds.Contains(w.Id) && w.istemplate != true) + .Include(w => w.Locations) + .ToListAsync(cancellationToken); + + var byId = loaded.ToDictionary(w => w.Id); + var ordered = new List(orderedIds.Count); + foreach (var id in orderedIds) + { + if (byId.TryGetValue(id, out var workOrder)) + ordered.Add(workOrder); + } + + return ordered; + } + + public async Task GetWorkOrderDetailAsync(int id) + { + return await _context.workOrders + .AsNoTracking() + .Where(w => w.Id == id) + .Select(s => new WorkOrderDetailReadModel + { + Id = s.Id, + InternalWONumber = s.InternalWONumber, + WorkerOrderNumber = s.WorkerOrderNumber, + Title = s.WorkerOrderTitle, + Description = s.Description, + CreatedDate = s.CreatedDate, + DueDate = s.DueDate, + ScheduledDate = s.ScheduledDate, + CompletedDate = s.CompletedDate, + Status = s.Status, + Assignee = s.AssignToUser != null ? s.AssignToUser.FirstName : "", + Location = s.Locations != null ? s.Locations.Name : "", + LocationId = s.LocationId, + LocationZip = s.Locations != null ? (s.Locations.Zip ?? ExtractZip(s.Locations.Address1)) : "", + Priority = s.Priority, + Problem = s.Problem, + Trade = s.Trade, + SubTrade = s.SubTrade, + VendorNTE = s.VendorNTE, + Source = s.Source, + PO = s.PO, + TT = s.TT, + Poc = s.WorkOrderContacts!.Select(c => new ContactReadModel + { + Id = c.Id, + Name = c.POC != null ? (c.POC.FirstName + " " + c.POC.LastName).Trim() : "", + Email = c.POC != null ? c.POC.Email : "", + Phone = c.POC != null ? c.POC.PhoneNumber : "" + }).ToList(), + Category = s.WorkOrderCategories!.Select(c => new CategoryReadModel + { + Id = c.Id, + Name = c.Category != null ? c.Category.Name : "" + }).ToList(), + Attachments = s.workOrderAttachments!.Select(a => new AttachmentReadModel + { + Id = a.Id, + Attachment = a.Attachments + }).ToList(), + BeforPhotoIssue = s.BeforPhotoIssue, + BeforPhotoAttachment = s.BeforPhotoAttachment, + AfterPhotoIssue = s.AfterPhotoIssue, + AfterPhotoAttachment = s.AfterPhotoAttachment, + SignOffName = s.SignOffName, + SignOffAttachment = s.SignOffAttachment, + SignOffSignature = s.SignOffSignature, + Comments = s.Comments!.Select(c => new CommentReadModel + { + Id = c.Id, + CreatedDate = c.CreatedDate, + Commenttext = c.Commenttext, + Commenter = c.Commenter, + RecordType = c.RecordType, + CommentType = c.CommentType, + Documents = c.Documents, + UserName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : "" + }).ToList(), + Quotes = s.Quotes!.Select(q => new QuoteRefReadModel + { + Code = q.QuotId, + Id = q.Id + }).ToList(), + AuditLog = _context.WorkOrderAuditLogs + .Where(a => a.WorkOrderId == s.Id) + .OrderByDescending(a => a.CreatedAt) + .Select(a => new AuditLogReadModel + { + Id = a.Id, + FieldName = a.FieldName, + OldValue = a.OldValue, + NewValue = a.NewValue, + Action = a.Action, + CreatedAt = a.CreatedAt, + UserName = a.User != null ? a.User.FirstName + " " + a.User.LastName : "" + }).ToList(), + Dispatches = s.Dispatches!.Select(d => new DispatchReadModel + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + NTEAmount = d.NTEAmount, + Description = d.Description, + Status = d.Status, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + DispatchedAt = d.DispatchedAt, + EmailSent = d.EmailSent, + VendorName = d.Vendor != null ? d.Vendor.CompanyName : "", + VendorEmail = d.Vendor != null ? d.Vendor.Email : "", + VendorId = d.VendorId + }).ToList() + }).FirstOrDefaultAsync(); + } + + public async Task> GetNonTemplateWorkOrdersWithLocationsAsync() + { + return await _context.workOrders + .Where(w => w.istemplate != true) + .Include(w => w.Locations) + .ToListAsync(); + } + + private static string? ExtractZip(string? address) + { + if (string.IsNullOrWhiteSpace(address)) return null; + var match = System.Text.RegularExpressions.Regex.Match(address, @"\b(\d{5})(?:-\d{4})?\b"); + return match.Success ? match.Groups[1].Value : null; + } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs new file mode 100644 index 0000000..74341d2 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs @@ -0,0 +1,48 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderFieldLockDataService : IWorkOrderFieldLockDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderFieldLockDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken) + { + if (IsTracked(workOrderId, fieldName)) + return true; + + return await _context.WorkOrderFieldLocks + .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName, cancellationToken); + } + + public async Task EnqueueLockAsync( + int workOrderId, + string fieldName, + string? userId, + CancellationToken cancellationToken) + { + if (await IsLockedAsync(workOrderId, fieldName, cancellationToken)) + return; + + _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = workOrderId, + FieldName = fieldName, + LockedAt = DateTime.UtcNow, + LockedByUserId = userId + }); + } + + private bool IsTracked(int workOrderId, string fieldName) + => _context.WorkOrderFieldLocks.Local.Any(l => + l.WorkOrderId == workOrderId && + string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs new file mode 100644 index 0000000..5a2a835 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs @@ -0,0 +1,57 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderIngestDataService : IWorkOrderIngestDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderIngestDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken) + => await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0; + + public Task GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWorkOrderId, cancellationToken); + + public void TrackWorkOrder(WorkOrder workOrder) + => _context.workOrders.Add(workOrder); + + public Task FindLocationAsync(string name, CancellationToken cancellationToken) + => _context.Locations.FirstOrDefaultAsync(l => l.Name == name || l.Title == name, cancellationToken); + + public async Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken) + { + _context.Locations.Add(location); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken) + { + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync(cancellationToken) + : null; + + try + { + await work(cancellationToken); + if (transaction is not null) + await transaction.CommitAsync(cancellationToken); + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + public Task SaveAsync(CancellationToken cancellationToken) + => _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs new file mode 100644 index 0000000..d21a804 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -0,0 +1,30 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderMediaDataService : IWorkOrderMediaDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderMediaDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + => _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); + + public Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken) + => _context.workOrderAttachments.FirstOrDefaultAsync( + a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true, + cancellationToken); + + public void TrackAttachment(WorkOrderAttachments attachment) + => _context.workOrderAttachments.Add(attachment); + + public Task SaveAsync(CancellationToken cancellationToken) + => _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderOpsHealthDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderOpsHealthDataService.cs new file mode 100644 index 0000000..ee9cc0c --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderOpsHealthDataService.cs @@ -0,0 +1,32 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderOpsHealthDataService : IWorkOrderOpsHealthDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderOpsHealthDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken) + => _context.WorkOrderAuditLogs + .CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken); + + public Task CountFieldLocksAsync(CancellationToken cancellationToken) + => _context.WorkOrderFieldLocks.CountAsync(cancellationToken); + + public async Task GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken) + { + if (!await _context.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken)) + return null; + + return await _context.WorkOrderWeekRolledLedgers + .MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/ICalendarDataService.cs b/SeaHaven.DataServices/Interfaces/ICalendarDataService.cs new file mode 100644 index 0000000..7797bc2 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ICalendarDataService.cs @@ -0,0 +1,14 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface ICalendarDataService + { + Task GetEventByIdAsync(int id, CancellationToken cancellationToken); + Task GetEventForEditAsync(int id, CancellationToken cancellationToken); + Task AddEventAsync(Events ev, CancellationToken cancellationToken); + Task UpdateEventAsync(Events ev, CancellationToken cancellationToken); + Task<(IEnumerable Items, int TotalCount)> GetEventListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + Task> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ICommentDataService.cs b/SeaHaven.DataServices/Interfaces/ICommentDataService.cs index e6bd08c..a4e87f7 100644 --- a/SeaHaven.DataServices/Interfaces/ICommentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ICommentDataService.cs @@ -12,5 +12,7 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(Comments comment); Task DeleteAsync(int id); Task ExistsAsync(int id); + Task StageAsync(Comments comment, CancellationToken cancellationToken); + Task> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/ICommonDataService.cs b/SeaHaven.DataServices/Interfaces/ICommonDataService.cs new file mode 100644 index 0000000..8d36d68 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ICommonDataService.cs @@ -0,0 +1,13 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface ICommonDataService + { + Task<(IEnumerable Items, int TotalCount)> GetUsersPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken); + Task> GetRoleNamesAsync(CancellationToken cancellationToken); + Task> GetSiteNamesAsync(CancellationToken cancellationToken); + Task> GetContactsAsync(CancellationToken cancellationToken); + Task> GetCategoriesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs new file mode 100644 index 0000000..7acb41f --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -0,0 +1,15 @@ +namespace SeaHaven.DataServices.Interfaces +{ + public interface IDashboardDataService + { + Task GetWorkOrderCountsAsync(CancellationToken cancellationToken); + } + + public class DashboardCounts + { + public int Total { get; set; } + public int Open { get; set; } + public int NotDispatched { get; set; } + public int Completed { get; set; } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs index 52473ba..efe7dcc 100644 --- a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs @@ -1,4 +1,6 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -6,6 +8,7 @@ namespace SeaHaven.DataServices.Interfaces { Task GetByIdAsync(int id); Task GetByIdWithDetailsAsync(int id); + Task GetByIdWithVendorAndWorkOrderAsync(int id); Task> GetAllAsync(); Task> GetByVendorIdAsync(int vendorId); Task> GetByWorkOrderIdAsync(int workOrderId); @@ -25,8 +28,29 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateChecklistItemAsync(DispatchChecklistItem item); Task> GetSignoffsAsync(int dispatchId); Task AddSignoffAsync(DispatchSignoff signoff); + Task StageSignoffAsync(DispatchSignoff signoff, CancellationToken cancellationToken); Task HasSignoffTypeAsync(int dispatchId, string signoffType); Task> GetDispatchWorkOrdersAsync(int dispatchId); Task AddDispatchWorkOrderAsync(DispatchWorkOrder dispatchWorkOrder); + + Task GetListAsync(DispatchListQuery query, CancellationToken cancellationToken); + Task GetDetailAsync(int id, CancellationToken cancellationToken); + Task GetDispatchDetailAsync(int id); + Task> GetVendorDispatchSummariesAsync( + int vendorId, string? status, CancellationToken cancellationToken); + Task GetVendorDispatchDetailAsync( + int id, int vendorId, CancellationToken cancellationToken); + Task GetVendorDispatchForMutationAsync( + int id, int vendorId, CancellationToken cancellationToken); + Task> GetPortalChecklistAsync( + int dispatchId, CancellationToken cancellationToken); + Task> GetPortalSignoffsAsync( + int dispatchId, CancellationToken cancellationToken); + Task GetChecklistItemForDispatchAsync( + int itemId, int dispatchId, CancellationToken cancellationToken); + Task StageAuditLogAsync(WorkOrderAuditLog log, CancellationToken cancellationToken); + Task GetDispatchDispatcherUserIdAsync( + int workOrderId, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IDropdownOptionsDataService.cs b/SeaHaven.DataServices/Interfaces/IDropdownOptionsDataService.cs new file mode 100644 index 0000000..063dcae --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IDropdownOptionsDataService.cs @@ -0,0 +1,15 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IDropdownOptionsDataService + { + Task> GetAllActiveAsync(string? category, CancellationToken cancellationToken); + Task> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken); + Task ExistsAsync(string category, string value, string? parentValue, CancellationToken cancellationToken); + Task AddAsync(DropdownOption option, CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task UpdateAsync(DropdownOption option, CancellationToken cancellationToken); + Task RemoveAsync(DropdownOption option, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IFollowUpDataService.cs b/SeaHaven.DataServices/Interfaces/IFollowUpDataService.cs index c8b8df3..6a6c5de 100644 --- a/SeaHaven.DataServices/Interfaces/IFollowUpDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IFollowUpDataService.cs @@ -15,5 +15,15 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteAsync(int id); Task ExistsAsync(int id); Task CountAsync(); + + Task> GetListPagedAsync(int page, int pageSize, string? search, string? status, CancellationToken cancellationToken); + Task GetListTotalAsync(string? search, string? status, CancellationToken cancellationToken); + Task GetDetailByIdAsync(int id, CancellationToken cancellationToken); + Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken); + Task GetUserNameAsync(string? userId, CancellationToken cancellationToken); + Task AddAsync(FollowUps followUp, CancellationToken cancellationToken); + Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken); + Task SetStatusAsync(int id, string status, CancellationToken cancellationToken); + Task RemoveByIdAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs new file mode 100644 index 0000000..720e665 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs @@ -0,0 +1,13 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IForgetPasswordDataService + { + Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken); + Task CodeExistsAsync(string code, CancellationToken cancellationToken); + Task ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken); + Task GetByEmailAsync(string email, CancellationToken cancellationToken); + Task RemoveByEmailAsync(string email, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index 679528e..a88609a 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -24,5 +24,12 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteAsync(int id); Task ExistsAsync(int id); Task CountAsync(); + + Task<(List Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + Task GetDetailByIdAsync(int id, CancellationToken cancellationToken); + Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken); + Task AddAsync(Locations location, CancellationToken cancellationToken); + Task UpdateAsync(Locations location, CancellationToken cancellationToken); + Task DeleteByIdAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs b/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs index 8357876..1f7a7da 100644 --- a/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -12,5 +13,15 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteAsync(int id); Task ExistsAsync(int id); Task UnlinkFromWorkOrderAsync(int workOrderId); + + Task<(IReadOnlyList Items, int TotalCount)> GetQuotesPagedAsync( + int page, int pageSize, string search, CancellationToken cancellationToken); + + Task GetQuoteDetailsByIdAsync(int id, CancellationToken cancellationToken); + + Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken); + + Task DeleteLineItemsByQuoteIdAsync(int quoteId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/ISettingsDataService.cs b/SeaHaven.DataServices/Interfaces/ISettingsDataService.cs new file mode 100644 index 0000000..78832a3 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ISettingsDataService.cs @@ -0,0 +1,16 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Dto; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface ISettingsDataService + { + Task AddCategoryAsync(string name, CancellationToken cancellationToken); + Task UpdateCategoryAsync(int id, string name, CancellationToken cancellationToken); + Task DeleteCategoryCascadeAsync(int id, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int Total)> GetCategoryListPagedAsync(int page, int pageSize, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int Total)> GetTemplatesPagedAsync(int page, int pageSize, CancellationToken cancellationToken); + Task<(IReadOnlyList Users, int Total)> GetUsersPagedAsync(string? search, int page, int pageSize, CancellationToken cancellationToken); + Task> GetRoleNamesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ISyncDataService.cs b/SeaHaven.DataServices/Interfaces/ISyncDataService.cs new file mode 100644 index 0000000..ee4d1be --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ISyncDataService.cs @@ -0,0 +1,25 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface ISyncDataService + { + Task GetLastInternalWONumberAsync(CancellationToken cancellationToken); + Task GetWorkOrderByExternalIdAsync(string externalId, CancellationToken cancellationToken); + void EnqueueWorkOrder(WorkOrder workOrder); + void EnqueueComment(Comments comment); + Task SaveChangesAsync(CancellationToken cancellationToken); + + Task ResolveLocationAsync(string? siteCode, string? building, string? address, CancellationToken cancellationToken); + Task GetLocationCountAsync(CancellationToken cancellationToken); + + Task CommentExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken); + Task GetWorkOrderByInternalNumberAsync(string internalNumber, CancellationToken cancellationToken); + Task GetDispatchByNumberAndWorkOrderIdAsync(string dispatchNumber, int workOrderId, CancellationToken cancellationToken); + + Task> GetWorkOrdersMissingInternalNumberAsync(CancellationToken cancellationToken); + Task> GetDispatchesMissingNumberAsync(CancellationToken cancellationToken); + Task ExecuteBackfillCommentTypesAsync(CancellationToken cancellationToken); + Task GetLastDispatchNumberAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ITaskListTemplateDataService.cs b/SeaHaven.DataServices/Interfaces/ITaskListTemplateDataService.cs index ac763d3..0c63709 100644 --- a/SeaHaven.DataServices/Interfaces/ITaskListTemplateDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ITaskListTemplateDataService.cs @@ -11,5 +11,13 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(TaskListTemplate template); Task DeleteAsync(int id); Task ExistsAsync(int id); + + // Cancelable query/command operations used by the TaskListTemplate service layer + Task> GetActiveTemplatesWithItemsAsync(CancellationToken cancellationToken); + Task GetTemplateWithItemsReadOnlyAsync(int id, CancellationToken cancellationToken); + Task GetTemplateWithItemsForUpdateAsync(int id, CancellationToken cancellationToken); + Task AddTemplateAsync(TaskListTemplate template, CancellationToken cancellationToken); + Task ReplaceTemplateItemsAsync(TaskListTemplate template, IEnumerable newItems, CancellationToken cancellationToken); + Task DeactivateTemplateAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs new file mode 100644 index 0000000..ee7ead2 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -0,0 +1,17 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IUpliftDataService + { + Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( + string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); + Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken); + Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); + Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 6cfed2d..40dc64a 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Dto; namespace SeaHaven.DataServices.Interfaces { @@ -8,6 +9,15 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAllAsync(); Task GetByEmailAsync(string email); Task ExistsAsync(string id); + + Task> GetAllForListAsync(CancellationToken cancellationToken); + Task GetProfileAsync(string id, CancellationToken cancellationToken); + Task UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken); + Task GetForEditAsync(string id, CancellationToken cancellationToken); + Task GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken); + Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); + Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); + Task GetEmailByIdAsync(string userId, CancellationToken cancellationToken); Task> GetDisplayNamesByIdsAsync(IEnumerable ids); } } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index b0acd6d..3324b6e 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -6,7 +6,13 @@ namespace SeaHaven.DataServices.Interfaces { Task GetByIdAsync(int id); Task GetByIdWithDetailsAsync(int id); + + // Batched lookup: one query for the distinct requested ids, preserving input order + // and skipping any ids that are not present (no rows returned for missing ids). + Task> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken); Task> GetAllAsync(); + Task> GetActiveVendorsAsync(CancellationToken cancellationToken); + Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken); Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, @@ -20,8 +26,11 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(Vendor vendor); Task DeleteAsync(int id); Task ExistsAsync(int id); - Task GetActiveTokenAsync(int vendorId); - Task GetTokenByStringAsync(string token); + Task GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken); + Task GetTokenWithVendorAsync(string token, CancellationToken cancellationToken); + Task AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken); + Task> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); Task CountAsync(); Task GetCompanyByIdAsync(int id); diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs new file mode 100644 index 0000000..5849b51 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IVendorDocumentDataService + { + Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs new file mode 100644 index 0000000..b8c71c1 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IVendorOperationsDataService.cs @@ -0,0 +1,33 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IVendorOperationsDataService + { + Task> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken); + Task> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection excludeIds, CancellationToken cancellationToken); + Task> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken); + Task> GetActiveVendorsAsync(CancellationToken cancellationToken); + Task> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken); + Task> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken); + Task LocationExistsAsync(int locationId, CancellationToken cancellationToken); + Task> GetVendorsByIdsAsync(IReadOnlyCollection vendorIds, CancellationToken cancellationToken); + Task> GetInsightDispatchDataAsync(DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken); + + Task GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken); + Task GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken); + Task GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken); + Task GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken); + Task GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + + Task GenerateNextDispatchNumberAsync(CancellationToken cancellationToken); + Task GenerateNextPONumberAsync(CancellationToken cancellationToken); + Task AddDispatchAsync(Dispatch dispatch, CancellationToken cancellationToken); + Task StageWorkOrderAuditAsync(WorkOrderAuditLog log, CancellationToken cancellationToken); + Task StageVendorAuditAsync(VendorAuditLog log, CancellationToken cancellationToken); + Task AddSitePreferredVendorAsync(SitePreferredVendor preference, CancellationToken cancellationToken); + Task RemoveSitePreferredVendorsAsync(IEnumerable preferences, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs new file mode 100644 index 0000000..d1bcef4 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderAuditDataService + { + void EnqueueAuditLog(WorkOrderAuditLog auditLog); + + Task PersistAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs new file mode 100644 index 0000000..291728b --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public enum BoardSaveOutcome + { + Saved, + ConcurrencyConflict + } + + public interface IWorkOrderBoardMutationDataService + { + Task VendorExistsAsync(int vendorId, CancellationToken cancellationToken); + Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken); + Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken); + Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken); + void TrackNewWorkOrder(WorkOrder workOrder); + void TrackNewDispatch(Dispatch dispatch); + void TrackWorkOrderContact(int workOrderId, int contactId, string? notes); + void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); + void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version); + bool HasPendingChanges(); + Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken); + Task SaveAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs new file mode 100644 index 0000000..aa6174c --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderCompletionDataService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderCompletionDataService + { + Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); + Task GetTrackedTemplateAsync(int id, CancellationToken cancellationToken); + Task SaveAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs index 3ce62e5..3edbd12 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -13,7 +14,7 @@ namespace SeaHaven.DataServices.Interfaces Task> GetByAssigneeAsync(string assigneeId); Task> GetByStatusAsync(string status); Task> GetByPriorityAsync(string priority); - + // Paged results Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, @@ -23,7 +24,7 @@ namespace SeaHaven.DataServices.Interfaces string? priority = null); // Paged list with detailed information (for GetWorkOrderList endpoint) - Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search = null, @@ -32,12 +33,12 @@ namespace SeaHaven.DataServices.Interfaces int? locationId = null, string? sortBy = "lastUpdated", string? sortDir = "desc"); - + // Command operations Task AddAsync(WorkOrder workOrder); Task UpdateAsync(WorkOrder workOrder); Task DeleteAsync(int id); - + // Related entities - Read Task> GetWorkOrderContactsAsync(int workOrderId); Task> GetWorkOrderCategoriesAsync(int workOrderId); @@ -50,6 +51,7 @@ namespace SeaHaven.DataServices.Interfaces Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable categoryIds); Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment); Task AddAuditLogAsync(WorkOrderAuditLog auditLog); + Task AddAuditLogsAsync(IReadOnlyCollection auditLogs); // Related entities - Delete Task DeleteWorkOrderContactsAsync(int workOrderId); @@ -66,7 +68,18 @@ namespace SeaHaven.DataServices.Interfaces // Statistics Task CountAsync(); Task CountByStatusAsync(string status); + Task CountByPriorityAsync(string priority); Task CountByLocationAsync(int locationId); Task> GetAllWithDetailsAsync(); + + // Batched lookup: server-filters the distinct requested ids, excludes templates, + // eagerly loads the location detail, deduplicates, and returns results in input order. + Task> GetByRequestedIdsWithDetailsAsync( + IReadOnlyCollection ids, + CancellationToken cancellationToken); + + // Complex read models + Task GetWorkOrderDetailAsync(int id); + Task> GetNonTemplateWorkOrdersWithLocationsAsync(); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs new file mode 100644 index 0000000..cc95713 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs @@ -0,0 +1,15 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderFieldLockDataService + { + Task IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken); + + Task EnqueueLockAsync( + int workOrderId, + string fieldName, + string? userId, + CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs new file mode 100644 index 0000000..b97d7c1 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs @@ -0,0 +1,15 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderIngestDataService + { + Task GetMaxWorkOrderIdAsync(CancellationToken cancellationToken); + Task GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken); + void TrackWorkOrder(WorkOrder workOrder); + Task FindLocationAsync(string name, CancellationToken cancellationToken); + Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken); + Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken); + Task SaveAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs new file mode 100644 index 0000000..db1d07e --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderMediaDataService + { + Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); + void TrackAttachment(WorkOrderAttachments attachment); + Task SaveAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderOpsHealthDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderOpsHealthDataService.cs new file mode 100644 index 0000000..1ef4a52 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderOpsHealthDataService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderOpsHealthDataService + { + Task CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken); + Task CountFieldLocksAsync(CancellationToken cancellationToken); + Task GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Models/ReadModels.cs b/SeaHaven.DataServices/Models/ReadModels.cs new file mode 100644 index 0000000..71a5c76 --- /dev/null +++ b/SeaHaven.DataServices/Models/ReadModels.cs @@ -0,0 +1,260 @@ +namespace SeaHaven.DataServices.Models +{ + public class WorkOrderListItemReadModel + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkOrderNumber { get; set; } + public string? Number { get; set; } + public string? Title { get; set; } + public string? Location { get; set; } + public string? LocationName { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DueDate { get; set; } + public string? AssignedTo { get; set; } + public string? EmployeeName { get; set; } + public DateTime? LastUpdated { get; set; } + } + + public class WorkOrderDetailReadModel + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Status { get; set; } + public string? Assignee { get; set; } + public string? Location { get; set; } + public int? LocationId { get; set; } + public string? LocationZip { get; set; } + public string? Priority { get; set; } + public string? Problem { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public decimal? VendorNTE { get; set; } + public string? Source { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public List Poc { get; set; } = new(); + public List Category { get; set; } = new(); + public List Attachments { get; set; } = new(); + public string? BeforPhotoIssue { get; set; } + public string? BeforPhotoAttachment { get; set; } + public string? AfterPhotoIssue { get; set; } + public string? AfterPhotoAttachment { get; set; } + public string? SignOffName { get; set; } + public string? SignOffAttachment { get; set; } + public string? SignOffSignature { get; set; } + public List Comments { get; set; } = new(); + public List Quotes { get; set; } = new(); + public List AuditLog { get; set; } = new(); + public List Dispatches { get; set; } = new(); + } + + public class ContactReadModel + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + } + + public class CategoryReadModel + { + public int Id { get; set; } + public string? Name { get; set; } + } + + public class AttachmentReadModel + { + public int Id { get; set; } + public string? Attachment { get; set; } + } + + public class CommentReadModel + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? RecordType { get; set; } + public string? CommentType { get; set; } + public string? Documents { get; set; } + public string? UserName { get; set; } + } + + public class QuoteRefReadModel + { + public string? Code { get; set; } + public int Id { get; set; } + } + + public class AuditLogReadModel + { + public int Id { get; set; } + public string? FieldName { get; set; } + public string? OldValue { get; set; } + public string? NewValue { get; set; } + public string? Action { get; set; } + public DateTime CreatedAt { get; set; } + public string? UserName { get; set; } + } + + public class DispatchReadModel + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public bool? EmailSent { get; set; } + public string? VendorName { get; set; } + public string? VendorEmail { get; set; } + public int? VendorId { get; set; } + } + + public class DispatchDetailReadModel + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public bool? EmailSent { get; set; } + public int? WorkOrderId { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public string? VerifiedBy { get; set; } + public DateTime? VerifiedAt { get; set; } + public string? WoNumber { get; set; } + public string? WoTitle { get; set; } + public string? VendorName { get; set; } + public string? VendorEmail { get; set; } + public int? VendorId { get; set; } + public List WorkOrders { get; set; } = new(); + public List ChecklistItems { get; set; } = new(); + public List Signoffs { get; set; } = new(); + public List Comments { get; set; } = new(); + } + + public class DispatchWorkOrderReadModel + { + public int? WorkOrderId { get; set; } + public string? WoNumber { get; set; } + public string? WoTitle { get; set; } + } + + public class ChecklistItemReadModel + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool? IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + public int? SortOrder { get; set; } + public int? WorkOrderId { get; set; } + } + + public class SignoffReadModel + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? Signature { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class DispatchCommentReadModel + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public string? UserName { get; set; } + } + + public class FilteredWorkOrderReadModel + { + public int Id { get; set; } + public string? WorkerOrderTitle { get; set; } + public DateTime? DueDate { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public string? Name { get; set; } + } + + public class CommentListItemReadModel + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? FirstName { get; set; } + public string? Documents { get; set; } + } + + public class QuoteListItemReadModel + { + public int Id { get; set; } + public string? QuoteId { get; set; } + public string? WorkOrder { get; set; } + public string? ProjectName { get; set; } + public decimal TotalAmount { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? BillingAddress { get; set; } + public string? Status { get; set; } + public int? ContactId { get; set; } + public DateTime? Date { get; set; } + public List Lineitems { get; set; } = new(); + } + + public class QuoteDetailReadModel + { + public int Id { get; set; } + public string? QuotId { get; set; } + public DateTime? Date { get; set; } + public string? Name { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? SiteName { get; set; } + public string? ProjectName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public string? ShippingAddress { get; set; } + public decimal TotalAmount { get; set; } + public List Lineitems { get; set; } = new(); + } + + public class QuoteLineItemReadModel + { + public string? Descriptions { get; set; } + public decimal? Lineitem { get; set; } + public int? LineitemNumber { get; set; } + } + + public class WorkOrderQuoteDetailReadModel + { + public int Id { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? Title { get; set; } + public string? Name { get; set; } + } +} diff --git a/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs b/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs new file mode 100644 index 0000000..2eb630e --- /dev/null +++ b/SeaHaven.DataServices/Models/VendorOperationsReadModels.cs @@ -0,0 +1,43 @@ +namespace SeaHaven.DataServices.Models; + +public sealed class ScheduledDispatchReadModel +{ + public int Id { get; set; } + public int VendorId { get; set; } + public string? VendorName { get; set; } + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } +} + +public sealed class NoVendorCandidateReadModel +{ + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public DateTime? ScheduledDate { get; set; } +} + +public sealed class ActiveVendorReadModel +{ + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? TradeSpecialties { get; set; } + public string? AvailabilityStatus { get; set; } + public DateTime? AvailabilityUpdatedAt { get; set; } +} + +public sealed class InsightDispatchData +{ + public int VendorId { get; set; } + public string? VendorName { get; set; } + public string? Status { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DueDate { get; set; } +} diff --git a/SeaHaven.Services/Configuration/ServiceOptions.cs b/SeaHaven.Services/Configuration/ServiceOptions.cs new file mode 100644 index 0000000..40999c1 --- /dev/null +++ b/SeaHaven.Services/Configuration/ServiceOptions.cs @@ -0,0 +1,38 @@ +namespace SeaHaven.Services.Configuration; + +public sealed class FrontendOptions +{ + public string FrontendBaseUrl { get; set; } = string.Empty; +} + +public sealed class JwtOptions +{ + public const string SectionName = "JWT"; + + public string Secret { get; set; } = string.Empty; + public string? ValidIssuer { get; set; } + public string? ValidAudience { get; set; } +} + +public sealed class ApprovalsOptions +{ + public const string SectionName = "Approvals"; + + public decimal? UpliftTier1MaxUsd { get; set; } + public string[] Tier1Roles { get; set; } = Array.Empty(); + public string[] Tier2Roles { get; set; } = Array.Empty(); +} + +public sealed class VendorPortalOptions +{ + public const string SectionName = "VendorPortal"; + + public int? TokenLifetimeDays { get; set; } +} + +public sealed class VendorDocumentsOptions +{ + public const string SectionName = "VendorDocuments"; + + public long MaxSizeBytes { get; set; } = 10 * 1024 * 1024; +} diff --git a/SeaHaven.Services/DTOs/CalendarDTOs.cs b/SeaHaven.Services/DTOs/CalendarDTOs.cs new file mode 100644 index 0000000..5e5b1c5 --- /dev/null +++ b/SeaHaven.Services/DTOs/CalendarDTOs.cs @@ -0,0 +1,50 @@ +namespace SeaHaven.Services.DTOs +{ + public class EventDTO + { + public int Id { get; set; } + public string Title { get; set; } = null!; + public string? Description { get; set; } + public string? Location { get; set; } + + public int? RecordID { get; set; } + public string? RecordName { get; set; } + public string? RecordTargetType { get; set; } + + public int ReminderMinutes { get; set; } + public string EventColor { get; set; } = null!; + + public DateTime StartDate { get; set; } + public string? StartTime { get; set; } + public DateTime EndDate { get; set; } + public string? EndTime { get; set; } + public bool AllDay { get; set; } + + public DateTime? CreatedDate { get; set; } + public string? createdby { get; set; } + } + + public class PagedEventsResult + { + public List Rows { get; set; } = new(); + public int TotalCount { get; set; } + } + + public class EditEventInput + { + public int? Id { get; set; } + public string Title { get; set; } = null!; + public string? Description { get; set; } + public string? Location { get; set; } + public int? RecordID { get; set; } + public string? RecordName { get; set; } + public string? RecordTargetType { get; set; } + public int ReminderMinutes { get; set; } + public string EventColor { get; set; } = null!; + public DateTime StartDate { get; set; } + public string? StartTime { get; set; } + public DateTime EndDate { get; set; } + public string? EndTime { get; set; } + public bool AllDay { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/CommonDTOs.cs b/SeaHaven.Services/DTOs/CommonDTOs.cs new file mode 100644 index 0000000..639da7d --- /dev/null +++ b/SeaHaven.Services/DTOs/CommonDTOs.cs @@ -0,0 +1,48 @@ +namespace SeaHaven.Services.DTOs +{ + public class UserListItemDTO + { + public string? Id { get; set; } + public string? UserName { get; set; } + public string? Email { get; set; } + public string? RoleName { get; set; } + public string? Status { get; set; } + public string? Date { get; set; } + } + + public class PagedUsersResult + { + public List Users { get; set; } = new(); + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public int TotalPages { get; set; } + } + + public class RoleListItemDTO + { + public string? Id { get; set; } + public string? Name { get; set; } + } + + public class SiteNameDTO + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Title { get; set; } + } + + public class ContactListItemDTO + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + } + + public class CategoryListItemDTO + { + public int Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs new file mode 100644 index 0000000..6bac68c --- /dev/null +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -0,0 +1,10 @@ +namespace SeaHaven.Services.DTOs +{ + public class DashboardStatsDTO + { + public int Total { get; set; } + public int Open { get; set; } + public int NotDispatched { get; set; } + public int Completed { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/DispatchDTOs.cs b/SeaHaven.Services/DTOs/DispatchDTOs.cs index 49a4a53..1a68b6c 100644 --- a/SeaHaven.Services/DTOs/DispatchDTOs.cs +++ b/SeaHaven.Services/DTOs/DispatchDTOs.cs @@ -27,4 +27,158 @@ namespace SeaHaven.Services.DTOs public DateTime? CompletedDate { get; set; } public string? Status { get; set; } } + + public class DispatchListEntryDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public decimal? NTEAmount { get; set; } + public string? Status { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public DateTime? VerifiedAt { get; set; } + public int? VendorId { get; set; } + public string? VendorName { get; set; } + public int? WorkOrderId { get; set; } + public string? PrimaryWOInternalNumber { get; set; } + public string? PrimaryWOTitle { get; set; } + public string? LocationName { get; set; } + public int WorkOrderCount { get; set; } + public int PendingUpliftCount { get; set; } + } + + public class DispatchListResultDTO + { + public IReadOnlyList Items { get; set; } = Array.Empty(); + public int Total { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + } + + public class DispatchDetailVendorResultDTO + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? TradeSpecialties { get; set; } + } + + public class DispatchDetailLocationResultDTO + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Address1 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + } + + public class DispatchDetailLinkedWorkOrderDTO + { + public int WorkOrderId { get; set; } + public string? InternalWONumber { get; set; } + public string? Title { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public string? LocationName { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public string? Problem { get; set; } + } + + public class DispatchDetailChecklistDTO + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + public int SortOrder { get; set; } + public int? WorkOrderId { get; set; } + } + + public class DispatchDetailSignoffDTO + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? Signature { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class DispatchDetailCommentDTO + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public string? RecordType { get; set; } + public DateTime? CreatedDate { get; set; } + public string? UserId { get; set; } + public string UserName { get; set; } = ""; + } + + public class DispatchDetailUpliftDTO + { + public int Id { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public decimal Delta { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string DecidedByName { get; set; } = ""; + } + + public class DispatchDetailAuditLogDTO + { + public int Id { get; set; } + public string? Action { get; set; } + public string? FieldName { get; set; } + public string? OldValue { get; set; } + public string? NewValue { get; set; } + public DateTime? CreatedAt { get; set; } + public string? UserId { get; set; } + public string UserName { get; set; } = ""; + } + + public class DispatchDetailResultDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public DateTime? VerifiedAt { get; set; } + public string? VerifiedBy { get; set; } + public bool EmailSent { get; set; } + public int? WorkOrderId { get; set; } + public DispatchDetailVendorResultDTO? Vendor { get; set; } + public DispatchDetailLocationResultDTO? PrimaryLocation { get; set; } + public IReadOnlyList WorkOrders { get; set; } = Array.Empty(); + public IReadOnlyList Checklist { get; set; } = Array.Empty(); + public IReadOnlyList Signoffs { get; set; } = Array.Empty(); + public IReadOnlyList Comments { get; set; } = Array.Empty(); + public IReadOnlyList UpliftRequests { get; set; } = Array.Empty(); + public IReadOnlyList AuditLog { get; set; } = Array.Empty(); + } } diff --git a/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs b/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs new file mode 100644 index 0000000..ede579e --- /dev/null +++ b/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs @@ -0,0 +1,131 @@ +namespace SeaHaven.Services.DTOs +{ + public class DispatchVerificationException : Exception + { + public List Missing { get; } + public DispatchVerificationException(List missing) : base("Cannot verify") + { + Missing = missing; + } + } + + public class DispatchToVendorInput + { + public List WorkOrderIds { get; set; } = new(); + public List VendorIds { get; set; } = new(); + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? TaskListTemplateId { get; set; } + public List? CustomChecklistItems { get; set; } + public string? UserId { get; set; } + public string? SenderName { get; set; } + } + + public class DispatchResultItem + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? VendorName { get; set; } + public string? Status { get; set; } + public int WorkOrderCount { get; set; } + } + + public class DispatchToVendorResult + { + public string Message { get; set; } = ""; + public List Dispatches { get; set; } = new(); + } + + public class UpdateDispatchInput + { + public int Id { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Description { get; set; } + public string? UserId { get; set; } + } + + public class AddDispatchCommentInput + { + public int DispatchId { get; set; } + public string? Text { get; set; } + public bool SendEmail { get; set; } + public string? UserId { get; set; } + } + + public class AddDispatchCommentResult + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? CommentType { get; set; } + public string? UserName { get; set; } + } + + public class AddDispatchSignoffInput + { + public int DispatchId { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? Signature { get; set; } + public string? SignatureMethod { get; set; } + } + + public class AddDispatchSignoffResult + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public DateTime? SignedAt { get; set; } + public string? SignatureMethod { get; set; } + } + + public class VerifyDispatchResult + { + public string Message { get; set; } = ""; + public string? VerifiedBy { get; set; } + public DateTime? VerifiedAt { get; set; } + } + + public class VerifyDispatchError + { + public string Status { get; set; } = "Error"; + public string Message { get; set; } = ""; + public List Missing { get; set; } = new(); + } + + public class ChecklistItemUpdateInput + { + public int Id { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + } + + public class ChecklistItemUpdateResult + { + public string Message { get; set; } = ""; + public int Id { get; set; } + public bool? IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class AddChecklistItemInput + { + public int DispatchId { get; set; } + public int? WorkOrderId { get; set; } + public string? ItemText { get; set; } + } + + public class AddChecklistItemResult + { + public int Id { get; set; } + public string? ItemText { get; set; } + public int? SortOrder { get; set; } + public bool? IsCompleted { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/DropdownOptionDTOs.cs b/SeaHaven.Services/DTOs/DropdownOptionDTOs.cs new file mode 100644 index 0000000..754a7f7 --- /dev/null +++ b/SeaHaven.Services/DTOs/DropdownOptionDTOs.cs @@ -0,0 +1,12 @@ +namespace SeaHaven.Services.DTOs +{ + public class DropdownOptionDTO + { + public int Id { get; set; } + public string Category { get; set; } = ""; + public string Value { get; set; } = ""; + public string? ParentValue { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + } +} diff --git a/SeaHaven.Services/DTOs/FollowUpDTOs.cs b/SeaHaven.Services/DTOs/FollowUpDTOs.cs index 4200416..1575296 100644 --- a/SeaHaven.Services/DTOs/FollowUpDTOs.cs +++ b/SeaHaven.Services/DTOs/FollowUpDTOs.cs @@ -40,4 +40,69 @@ namespace SeaHaven.Services.DTOs public int? AccountId { get; set; } public int? LocationId { get; set; } } + + public class FollowUpCreateRequestDTO + { + public string? Reason { get; set; } + public DateTime ScheduleDate { get; set; } + public TimeSpan ScheduleStartTime { get; set; } + public int? EmployeeId { get; set; } + public int? WorkOrderId { get; set; } + public int? AccountId { get; set; } + public int? LocationId { get; set; } + } + + public class FollowUpUpdateRequestDTO + { + public int Id { get; set; } + public string? Reason { get; set; } + public DateTime ScheduleDate { get; set; } + public TimeSpan ScheduleStartTime { get; set; } + public int? EmployeeId { get; set; } + public int? WorkOrderId { get; set; } + public int? AccountId { get; set; } + public int? LocationId { get; set; } + } + + public class FollowUpListRowDTO + { + public int Id { get; set; } + public string? Reason { get; set; } + public DateTime ScheduleDate { get; set; } + public TimeSpan ScheduleStartTime { get; set; } + public int? EmployeeId { get; set; } + public int? WorkOrderId { get; set; } + public int? AccountId { get; set; } + public int? LocationId { get; set; } + public string AccountName { get; set; } = ""; + public string AssignedTo { get; set; } = ""; + public string LocationName { get; set; } = ""; + public string WorkOrderNumber { get; set; } = ""; + public string InternalWONumber { get; set; } = ""; + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public string? CreatedById { get; set; } + public string CreatedByName { get; set; } = ""; + } + + public class FollowUpDetailDTO + { + public int Id { get; set; } + public string? Reason { get; set; } + public DateTime ScheduleDate { get; set; } + public TimeSpan ScheduleStartTime { get; set; } + public int? EmployeeId { get; set; } + public int? WorkOrderId { get; set; } + public int? AccountId { get; set; } + public int? LocationId { get; set; } + public string AccountName { get; set; } = ""; + public string AssignedTo { get; set; } = ""; + public string LocationName { get; set; } = ""; + public string WorkOrderNumber { get; set; } = ""; + public string InternalWONumber { get; set; } = ""; + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public string? CreatedById { get; set; } + public string CreatedByName { get; set; } = ""; + } } diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs new file mode 100644 index 0000000..94df6dc --- /dev/null +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -0,0 +1,68 @@ +namespace SeaHaven.Services.DTOs +{ + public class LoginResultDTO + { + public string Token { get; set; } = string.Empty; + public DateTime Expiration { get; set; } + public string? Email { get; set; } + public string? UserRole { get; set; } + public string? PhoneNumber { get; set; } + public string Fullname { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + } + + public class UpdateProfileRequestDTO + { + public string? Name { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } + + public class UserProfileDTO + { + public string? FirstName { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } + + public class AddUserRequestDTO + { + public string? Name { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + public string? Role { get; set; } + } + + public class AddUserOutcomeDTO + { + public bool Success { get; set; } + public string? Error { get; set; } + } + + public class EditUserRequestDTO + { + public string? Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Role { get; set; } + } + + public class UserListRowDTO + { + public string? RoleName { get; set; } + public string? Email { get; set; } + public string? Name { get; set; } + public string Date { get; set; } = string.Empty; + public string? Status { get; set; } + public string? Id { get; set; } + } + + public class UserProfileRowDTO + { + public string? Id { get; set; } + public string? Name { get; set; } + public DateTime? AddedDate { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 9b518d0..3a6edcd 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -37,6 +37,32 @@ namespace SeaHaven.Services.DTOs public int? AccountId { get; set; } } + public class LocationCreateRequestDTO + { + public string? Name { get; set; } + public string? Title { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? ContactEmail { get; set; } + public string? Status { get; set; } + } + + public class LocationUpdateRequestDTO + { + public string? Name { get; set; } + public string? Title { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? ContactEmail { get; set; } + public string? Status { get; set; } + } + public class SiteOptionDTO { public int Id { get; set; } diff --git a/SeaHaven.Services/DTOs/QuoteDTOs.cs b/SeaHaven.Services/DTOs/QuoteDTOs.cs new file mode 100644 index 0000000..ef7364c --- /dev/null +++ b/SeaHaven.Services/DTOs/QuoteDTOs.cs @@ -0,0 +1,108 @@ +namespace SeaHaven.Services.DTOs +{ + public class CreateQuoteInput + { + public string? QuotId { get; set; } + public int? ContactId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? ProjectName { get; set; } + public DateTime? Date { get; set; } + public int? WorkorderId { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public List? LineItems { get; set; } + } + + public class UpdateQuoteInput + { + public int Id { get; set; } + public string? QuotId { get; set; } + public int? ContactId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? ProjectName { get; set; } + public DateTime? Date { get; set; } + public int? WorkorderId { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public List? LineItems { get; set; } + } + + public class QuoteLineItemInput + { + public int? LineitemNumber { get; set; } + public decimal? Lineitem { get; set; } + public string? Descriptions { get; set; } + } + + public class PagedQuotesResult + { + public IReadOnlyList Items { get; set; } = Array.Empty(); + public int TotalCount { get; set; } + } + + public class QuoteListItemDTO + { + public int Id { get; set; } + public string? QuoteId { get; set; } + public string? WorkOrder { get; set; } + public string? ProjectName { get; set; } + public string? TotalAmount { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? BillingAddress { get; set; } + public string? Status { get; set; } + public int? ContactId { get; set; } + public DateTime? Date { get; set; } + public IReadOnlyList Lineitems { get; set; } = Array.Empty(); + } + + public class QuoteDetailDTO + { + public int Id { get; set; } + public string? QuotId { get; set; } + public DateTime? Date { get; set; } + public string? Name { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? SiteName { get; set; } + public string? ProjectName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public string? ShippingAddress { get; set; } + public string? TotalAmount { get; set; } + public IReadOnlyList Lineitems { get; set; } = Array.Empty(); + } + + public class QuoteLineItemDTO + { + public string? Descriptions { get; set; } + public decimal? Lineitem { get; set; } + public int? LineitemNumber { get; set; } + } + + public class WorkOrderQuoteDetailDTO + { + public int Id { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? Title { get; set; } + public string? Name { get; set; } + } + + public class QuoteReportingData + { + public int TotalCreatedWorkOrder { get; set; } + public int TotalCompleted { get; set; } + public int TotalSev1 { get; set; } + public int TotalSev2 { get; set; } + public int TotalSev3 { get; set; } + public int TotalSev4 { get; set; } + public int TotalSev5 { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/SettingsDTOs.cs b/SeaHaven.Services/DTOs/SettingsDTOs.cs new file mode 100644 index 0000000..f538dda --- /dev/null +++ b/SeaHaven.Services/DTOs/SettingsDTOs.cs @@ -0,0 +1,98 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class RoleDTO + { + public string? Name { get; set; } + public string? Description { get; set; } + } + + public class SettingsUserDTO + { + public string? Id { get; set; } + public string? UserName { get; set; } + public string? Email { get; set; } + public string? RoleName { get; set; } + public string? Status { get; set; } + public string? Date { get; set; } + } + + public class SettingsCategoryDTO + { + public int Id { get; set; } + public string? Name { get; set; } + } + + public class SettingsTemplateDTO + { + public int Id { get; set; } + public bool? IsDeleted { get; set; } + public string? createdby { get; set; } + public string? DeleterUserId { get; set; } + public DateTime? DeletionTime { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? LastModificationTime { get; set; } + public int? LastModifierUserId { get; set; } + + public string? InternalWONumber { get; set; } + public string? ExternalWorkOrderId { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Description { get; set; } + public string? Customer { get; set; } + public string? SiteCode { get; set; } + public string? Building { get; set; } + public string? Severity { get; set; } + public DateTime? DueDate { get; set; } + public string? AssignTo { get; set; } + public int? LocationId { get; set; } + public LifecycleStatus? LifecycleStatus { get; set; } + public string? LegacyStatus { get; set; } + public WorkOrderType? WorkOrderType { get; set; } + public string? Service { get; set; } + public int? PrimaryDispatchId { get; set; } + + public DateTime? ScheduledDate { get; set; } + public DateTime? ScheduledStart { get; set; } + public DateTime? ScheduledEnd { get; set; } + public DateOnly? TargetWeek { get; set; } + public bool? ScheduleWeekOnly { get; set; } + + public DateOnly? OriginalWeek { get; set; } + public DateOnly? OriginalDate { get; set; } + + public int RescheduleCount { get; set; } + public int CarriedOver { get; set; } + public OperationalFlags OperationalFlags { get; set; } + + public DocStatus? DocStatus { get; set; } + + public string? AvettaTask { get; set; } + public DateOnly? AssignDate { get; set; } + + public DateTime? DateReported { get; set; } + public string? SourceEmailS3Key { get; set; } + public string? Problem { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public decimal? VendorNTE { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Source { get; set; } + public bool? istemplate { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public string? Attachments { get; set; } + public string? BeforPhotoIssue { get; set; } + public string? BeforPhotoAttachment { get; set; } + public string? AfterPhotoIssue { get; set; } + public string? AfterPhotoAttachment { get; set; } + public string? SignOffName { get; set; } + public string? SignOffAttachment { get; set; } + public string? SignOffSignature { get; set; } + + public byte[]? RowVersion { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/SyncDTOs.cs b/SeaHaven.Services/DTOs/SyncDTOs.cs new file mode 100644 index 0000000..5973aaa --- /dev/null +++ b/SeaHaven.Services/DTOs/SyncDTOs.cs @@ -0,0 +1,28 @@ +namespace SeaHaven.Services.DTOs +{ + public class SyncWorkOrdersResult + { + public int Synced { get; set; } + public int Created { get; set; } + public int Updated { get; set; } + public int LocationsCreated { get; set; } + } + + public class SyncCommentsResult + { + public int Synced { get; set; } + public int Created { get; set; } + public int Skipped { get; set; } + } + + public class BackfillResult + { + public int Updated { get; set; } + } + + public class SyncAllResult + { + public SyncWorkOrdersResult? WorkOrders { get; set; } + public SyncCommentsResult? Comments { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/TaskListTemplateDTOs.cs b/SeaHaven.Services/DTOs/TaskListTemplateDTOs.cs new file mode 100644 index 0000000..9057525 --- /dev/null +++ b/SeaHaven.Services/DTOs/TaskListTemplateDTOs.cs @@ -0,0 +1,32 @@ +namespace SeaHaven.Services.DTOs +{ + public class TaskListTemplateDTO + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } + public List Items { get; set; } = new(); + } + + public class TaskListTemplateItemDTO + { + public int Id { get; set; } + public string? ItemText { get; set; } + public int SortOrder { get; set; } + } + + public class TaskListTemplateInput + { + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public List Items { get; set; } = new(); + } + + public class TaskListTemplateInputItem + { + public string? ItemText { get; set; } + public int SortOrder { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs new file mode 100644 index 0000000..0dcc483 --- /dev/null +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -0,0 +1,66 @@ +namespace SeaHaven.Services.DTOs +{ + public class UpliftListItemDTO + { + public int Id { get; set; } + public int DispatchId { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public int? WorkOrderId { get; set; } + public string? VendorCompanyName { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public decimal Delta { get; set; } + public string? VendorReason { get; set; } + public int RequiredTier { get; set; } + public string? Status { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public bool CanDecide { get; set; } + } + + public class UpliftListResultDTO + { + public int Total { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public IEnumerable Items { get; set; } = Enumerable.Empty(); + } + + public class UpliftForDispatchDTO + { + public int Id { get; set; } + public int DispatchId { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public decimal Delta { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByName { get; set; } + public bool CanDecide { get; set; } + } + + public class UpliftApproveResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + } + + public class UpliftDenyResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + } + + public class UpliftForbiddenException : Exception + { + public UpliftForbiddenException(string message) : base(message) { } + } +} diff --git a/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs b/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs new file mode 100644 index 0000000..9b98fc7 --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorDocumentDTOs.cs @@ -0,0 +1,36 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class UploadCompletionDocumentResultDTO + { + public int Id { get; set; } + public int Version { get; set; } + public string ScanStatus { get; set; } = "Pending"; + public string ReviewStatus { get; set; } = "Processing"; + } + + public enum VendorDocumentDownloadOutcome + { + Ok, + Locked, + NotFound + } + + public sealed class DownloadCompletionDocumentResultDTO + { + public VendorDocumentDownloadOutcome Outcome { get; set; } + public Stream? Content { get; set; } + public string? ContentType { get; set; } + public string? FileName { get; set; } + + public static DownloadCompletionDocumentResultDTO Ok(Stream content, string contentType, string fileName) => new() + { + Outcome = VendorDocumentDownloadOutcome.Ok, + Content = content, + ContentType = contentType, + FileName = fileName + }; + + public static DownloadCompletionDocumentResultDTO Locked() => new() { Outcome = VendorDocumentDownloadOutcome.Locked }; + public static DownloadCompletionDocumentResultDTO NotFound() => new() { Outcome = VendorDocumentDownloadOutcome.NotFound }; + } +} diff --git a/SeaHaven.Services/DTOs/VendorOperationsRequests.cs b/SeaHaven.Services/DTOs/VendorOperationsRequests.cs new file mode 100644 index 0000000..412aea8 --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorOperationsRequests.cs @@ -0,0 +1,36 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class VendorAvailabilityRequest + { + public string? Status { get; set; } + } + + public sealed class SitePreferenceRequest + { + public int VendorId { get; set; } + public string? Trade { get; set; } + public int? SortOrder { get; set; } + } + + public sealed class DocumentReviewRequest + { + public string ReviewStatus { get; set; } = ""; + public string? RejectionReason { get; set; } + } + + public sealed class CommercialStatusRequest + { + public string InvoiceStatus { get; set; } = "Not Submitted"; + public string PaymentStatus { get; set; } = "Unavailable"; + public string? InvoiceNumber { get; set; } + } + + public sealed class VendorAssignmentRequest + { + public int VendorId { get; set; } + public DateTime? ScheduledStart { get; set; } + public DateTime? ScheduledEnd { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/VendorOperationsResults.cs b/SeaHaven.Services/DTOs/VendorOperationsResults.cs new file mode 100644 index 0000000..98ededb --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorOperationsResults.cs @@ -0,0 +1,155 @@ +namespace SeaHaven.Services.DTOs +{ + public enum VendorOperationOutcomeKind { Ok, NotFound, BadRequest, Conflict } + + public sealed class VendorOperationOutcome + { + public VendorOperationOutcomeKind Kind { get; init; } + public T? Value { get; init; } + public string? Message { get; init; } + + public static VendorOperationOutcome Ok(T value) => new() { Kind = VendorOperationOutcomeKind.Ok, Value = value }; + public static VendorOperationOutcome NotFound(string? message = null) => new() { Kind = VendorOperationOutcomeKind.NotFound, Message = message }; + public static VendorOperationOutcome BadRequest(string message) => new() { Kind = VendorOperationOutcomeKind.BadRequest, Message = message }; + public static VendorOperationOutcome Conflict(string message) => new() { Kind = VendorOperationOutcomeKind.Conflict, Message = message }; + } + + public abstract class VendorNotificationItem + { + public DateTime TriggeredAt { get; set; } + } + + public sealed class NotificationActionDto + { + public string Label { get; set; } = ""; + public string Href { get; set; } = ""; + } + + public sealed class NoVendorNotification : VendorNotificationItem + { + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public string Severity { get; set; } = ""; + public string Group { get; set; } = ""; + public string Title { get; set; } = ""; + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime ScheduledAt { get; set; } + public NotificationActionDto Action { get; set; } = new(); + } + + public sealed class ConflictWorkOrderDto + { + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } + + public sealed class VendorConflictNotification : VendorNotificationItem + { + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public string Severity { get; set; } = ""; + public string Group { get; set; } = ""; + public string Title { get; set; } = ""; + public int VendorId { get; set; } + public string? VendorName { get; set; } + public IReadOnlyList WorkOrders { get; set; } = Array.Empty(); + public IReadOnlyList Actions { get; set; } = Array.Empty(); + } + + public sealed class VendorNotificationsResult + { + public DateTime GeneratedAt { get; set; } + public IReadOnlyList Items { get; set; } = Array.Empty(); + } + + public sealed class AvailabilityConflictDto + { + public int? WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } + + public sealed class VendorAvailabilityRow + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? TradeSpecialties { get; set; } + public string AvailabilityStatus { get; set; } = ""; + public DateTime? AvailabilityUpdatedAt { get; set; } + public bool IsPreferred { get; set; } + public int? PreferenceRank { get; set; } + public IReadOnlyList Conflicts { get; set; } = Array.Empty(); + } + + public sealed class VendorAvailabilityResult + { + public DateTime GeneratedAt { get; set; } + public int StaleAfterHours { get; set; } + public IReadOnlyList Data { get; set; } = Array.Empty(); + } + + public sealed class VendorAvailabilityUpdateResult + { + public int Id { get; set; } + public string? AvailabilityStatus { get; set; } + public DateTime? AvailabilityUpdatedAt { get; set; } + } + + public sealed class SitePreferenceRow + { + public int Id { get; set; } + public int LocationId { get; set; } + public int VendorId { get; set; } + public string? VendorName { get; set; } + public string? Trade { get; set; } + public int? SortOrder { get; set; } + public bool IsStale { get; set; } + public DateTime? LastModificationTime { get; set; } + public string? UpdatedBy { get; set; } + } + + public sealed class DocumentReviewResult + { + public int Id { get; set; } + public string? ReviewStatus { get; set; } + public string? RejectionReason { get; set; } + public DateTime? ReviewedAt { get; set; } + } + + public sealed class CommercialStatusResult + { + public int Id { get; set; } + public string? PONumber { get; set; } + public string? InvoiceNumber { get; set; } + public string? InvoiceStatus { get; set; } + public string? PaymentStatus { get; set; } + public DateTime? CommercialStatusUpdatedAt { get; set; } + } + + public sealed class VendorAssignmentResult + { + public int Id { get; set; } + public int VendorId { get; set; } + public string? VendorName { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + public string AvailabilityStatus { get; set; } = ""; + public IReadOnlyList Conflicts { get; set; } = Array.Empty(); + } + + public sealed class VendorInsightRow + { + public int VendorId { get; set; } + public string Vendor { get; set; } = ""; + public int CompletedJobs { get; set; } + public decimal CancellationRefusalRate { get; set; } + public decimal OnTimeArrivalRate { get; set; } + public decimal OnTimeCompletionRate { get; set; } + public double? AverageCycleHours { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/VendorPortalDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs new file mode 100644 index 0000000..8af1cfa --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs @@ -0,0 +1,65 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class VendorPortalTokenStateDTO + { + public required string Token { get; init; } + public DateTime IssuedAt { get; init; } + public DateTime ExpiresAt { get; init; } + public DateTime? LastUsedAt { get; init; } + } + + public class VendorPortalTokenDTO + { + public string Token { get; set; } = string.Empty; + public DateTime IssuedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime? LastUsedAt { get; set; } + public string PortalUrl { get; set; } = string.Empty; + } + + public class VendorDropdownItemDTO + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? TradeSpecialties { get; set; } + public string? Address { get; set; } + public double? DistanceMiles { get; set; } + } + + public class VendorFacetCompanyDTO + { + public string? Name { get; set; } + public int? CompanyId { get; set; } + public string? CompanyPhone { get; set; } + public string? Email { get; set; } + public string? PreferredContact { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? GoogleMapsUrl { get; set; } + } + + public class VendorFacetLocationDTO + { + public string? City { get; set; } + public string? State { get; set; } + public string? Label { get; set; } + } + + public class VendorJobBucketDTO + { + public string Id { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + } + + public class VendorFacetsDTO + { + public IEnumerable Companies { get; set; } = Enumerable.Empty(); + public IEnumerable Trades { get; set; } = Enumerable.Empty(); + public IEnumerable Locations { get; set; } = Enumerable.Empty(); + public IEnumerable JobBuckets { get; set; } = Enumerable.Empty(); + } +} diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs new file mode 100644 index 0000000..27cc8e7 --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -0,0 +1,164 @@ +namespace SeaHaven.Services.DTOs +{ + public class VendorPortalSession + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + } + + public class VendorDispatchSummaryDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public string? WorkOrderTitle { get; set; } + public string? InternalWONumber { get; set; } + public string? LocationName { get; set; } + public string? LocationCity { get; set; } + public string? LocationState { get; set; } + } + + public class PortalChecklistItemDTO + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class PortalSignoffDTO + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class PortalUpliftDTO + { + public int Id { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByName { get; set; } + } + + public class PortalCommentDTO + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public DateTime? CreatedDate { get; set; } + } + + public class PortalWorkOrderDTO + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Description { get; set; } + public string? Priority { get; set; } + public DateTime? DueDate { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public string? Problem { get; set; } + } + + public class PortalLocationDTO + { + public string? Name { get; set; } + public string? Address1 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + } + + public class VendorDispatchDetailDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public PortalWorkOrderDTO? WorkOrder { get; set; } + public PortalLocationDTO? Location { get; set; } + public IEnumerable Checklist { get; set; } = Enumerable.Empty(); + public IEnumerable Signoffs { get; set; } = Enumerable.Empty(); + public IEnumerable Comments { get; set; } = Enumerable.Empty(); + public IEnumerable UpliftRequests { get; set; } = Enumerable.Empty(); + } + + public class AcceptDispatchResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public DateTime? AcknowledgedAt { get; set; } + } + + public class ChangeStatusResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public DateTime? CompletedDate { get; set; } + } + + public class ChecklistItemResultDTO + { + public int Id { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class SignoffResultDTO + { + public int Id { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class CommentResultDTO + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public DateTime? CreatedDate { get; set; } + } + + public class UpliftRequestResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + } + + public class CancelUpliftResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs new file mode 100644 index 0000000..705306f --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Http; +using SeaHaven.DataServices.Models; + +namespace SeaHaven.Services.DTOs +{ + public class CreateWorkOrderWithDetailsInput + { + public string WorkerOrderTitle { get; set; } = ""; + public string? Description { get; set; } + public string Status { get; set; } = "Open"; + public string? Priority { get; set; } + public int? LocationId { get; set; } + public string? AssignTo { get; set; } + public DateTime? DueDate { get; set; } + public List? ContactIds { get; set; } + public List? CategoryIds { get; set; } + public List? Attachments { get; set; } + public IFormFile? BeforPhotoAttachment { get; set; } + public IFormFile? AfterPhotoAttachment { get; set; } + public IFormFile? SignOffAttachment { get; set; } + } + + public class UpdateWorkOrderWithDetailsInput + { + public int Id { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public int? LocationId { get; set; } + public string? AssignTo { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? CompletedDate { get; set; } + public List? ContactIds { get; set; } + public List? CategoryIds { get; set; } + public List? Attachments { get; set; } + public IFormFile? BeforPhotoAttachment { get; set; } + public IFormFile? AfterPhotoAttachment { get; set; } + public IFormFile? SignOffAttachment { get; set; } + } + + public class AddCommentInput + { + public int WorkorderId { get; set; } + public string? Text { get; set; } + public string? CommentType { get; set; } + public IFormFile? Document { get; set; } + } + + public class CommentResult + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? CommentType { get; set; } + public string? UserName { get; set; } + public string? Documents { get; set; } + } + + public class ChangeStatusResult + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + } + + public class ChangeAssignmentResult + { + public string Message { get; set; } = ""; + public string AssignedTo { get; set; } = ""; + } + + public class WorkorderDDItem + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public bool istemplate { get; set; } + } + + public class WorkorderFilterVM + { + public int? Id { get; set; } + public int? LocationId { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? BillingAddress { get; set; } = "Amazon.com Services LLC 410 Terry Ave,North Seattle, Washington, 98109"; + public string? WorkerOrderTitle { get; set; } + public string? LocationName { get; set; } + public string? AssignToUserName { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public DateTime? DueDate { get; set; } + public string FormattedDueDate => DueDate.HasValue ? DueDate.Value.ToString("MM/dd/yyyy") : ""; + public string? Dispatcher { get; set; } + public string? Scheduler { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + } + + public class FilteredWorkOrderPageDTO + { + public IReadOnlyList Data { get; set; } = + Array.Empty(); + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public int TotalPages { get; set; } + } +} diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index 12673b4..aa6f98f 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -1,5 +1,7 @@ using FluentValidation; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using SeaHaven.Services.Configuration; using System.Reflection; namespace SeaHaven.Services.DependencyInjection @@ -8,8 +10,14 @@ namespace SeaHaven.Services.DependencyInjection { private static readonly List ExcludedTypes = new(); - public static IServiceCollection AddBusinessServices(this IServiceCollection services) + public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration) { + services.Configure(configuration); + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure(configuration.GetSection(ApprovalsOptions.SectionName)); + services.Configure(configuration.GetSection(VendorPortalOptions.SectionName)); + services.Configure(configuration.GetSection(VendorDocumentsOptions.SectionName)); + var assembly = Assembly.GetExecutingAssembly(); var allClasses = assembly.GetTypes() diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs new file mode 100644 index 0000000..74c6de8 --- /dev/null +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -0,0 +1,160 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +namespace SeaHaven.Services.Implementation +{ + public class AuthenticationService : IAuthenticationService + { + private readonly UserManager _userManager; + private readonly JwtOptions _jwtOptions; + private readonly IUserDataService _userDataService; + private readonly IForgetPasswordDataService _forgetPasswordDataService; + private readonly IEmailSender _emailSender; + public AuthenticationService( + UserManager userManager, + IOptions jwtOptions, + IUserDataService userDataService, + IForgetPasswordDataService forgetPasswordDataService, + IEmailSender emailSender) + { + _userManager = userManager; + _jwtOptions = jwtOptions.Value; + _userDataService = userDataService; + _forgetPasswordDataService = forgetPasswordDataService; + _emailSender = emailSender; + } + + public async Task LoginAsync(string? username, string? password, CancellationToken cancellationToken) + { + var user = await _userManager.FindByNameAsync(username ?? ""); + if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? "")) + { + var userRoles = await _userManager.GetRolesAsync(user); + var authClaims = new List + { + new Claim(ClaimTypes.Name, user.UserName ?? ""), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + foreach (var userRole in userRoles) + { + authClaims.Add(new Claim(ClaimTypes.Role, userRole)); + } + var token = GetToken(authClaims); + return new LoginResultDTO + { + Token = new JwtSecurityTokenHandler().WriteToken(token), + Expiration = token.ValidTo, + Email = user.Email, + UserRole = userRoles.FirstOrDefault(), + PhoneNumber = user.PhoneNumber, + Fullname = user.FirstName + " " + user.LastName, + Id = user.Id + }; + } + + return null; + } + + public async Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return false; + + var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? ""); + return result.Succeeded; + } + + public async Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken) + { + var exists = await _userDataService.UpdateProfileAsync( + userId, + dto.Name, + dto.Email, + dto.Contact, + cancellationToken); + if (!exists) + return null; + + var updated = await _userDataService.GetProfileAsync(userId, cancellationToken); + if (updated == null) return null; + return new UserProfileDTO + { + FirstName = updated.FirstName, + Email = updated.Email, + Contact = updated.Contact + }; + } + + public async Task ForgetPasswordAsync(string email, CancellationToken cancellationToken) + { + var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken); + if (user == null) return false; + + var code = GenerateRandomNo(); + await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken); + var body = $"Your Password Reset Code is: " + code; + await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body); + return true; + } + + public async Task VerifyCodeAsync(string code, CancellationToken cancellationToken) + { + return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken); + } + + public async Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password)) + return false; + + var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken); + if (!matched) return false; + + var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); + if (record == null) + return false; + + var user = await _userManager.FindByIdAsync(record.UserId); + if (user == null) + return false; + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, password); + if (!result.Succeeded) + return false; + + await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken); + return true; + } + + private JwtSecurityToken GetToken(List authClaims) + { + var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret)); + var token = new JwtSecurityToken( + issuer: _jwtOptions.ValidIssuer, + audience: _jwtOptions.ValidAudience, + expires: DateTime.Now.AddDays(10), + claims: authClaims, + signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) + ); + return token; + } + + private static string GenerateRandomNo() + { + return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6"); + } + } +} diff --git a/SeaHaven.Services/Implementation/CalendarService.cs b/SeaHaven.Services/Implementation/CalendarService.cs new file mode 100644 index 0000000..cece13d --- /dev/null +++ b/SeaHaven.Services/Implementation/CalendarService.cs @@ -0,0 +1,125 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class CalendarService : ICalendarService + { + private readonly ICalendarDataService _dataService; + + public CalendarService(ICalendarDataService dataService) + { + _dataService = dataService; + } + + public async Task CreateEventAsync(EditEventInput input, CancellationToken cancellationToken) + { + var model = new Events + { + Title = input.Title, + Description = input.Description, + Location = input.Location, + RecordID = input.RecordID, + RecordName = input.RecordName, + RecordTargetType = input.RecordTargetType, + ReminderMinutes = input.ReminderMinutes, + EventColor = input.EventColor, + StartDate = input.StartDate, + StartTime = input.StartTime, + EndDate = input.EndDate, + EndTime = input.EndTime, + AllDay = input.AllDay, + CreatedDate = DateTime.Now, + IsDeleted = false + }; + + await _dataService.AddEventAsync(model, cancellationToken); + } + + public async Task UpdateEventAsync(EditEventInput input, CancellationToken cancellationToken) + { + var model = await _dataService.GetEventForEditAsync(input.Id!.Value, cancellationToken); + if (model == null || model.IsDeleted == true) + return false; + + model.Title = input.Title; + model.Description = input.Description; + model.Location = input.Location; + model.RecordID = input.RecordID; + model.RecordName = input.RecordName; + model.RecordTargetType = input.RecordTargetType; + model.ReminderMinutes = input.ReminderMinutes; + model.EventColor = input.EventColor; + model.StartDate = input.StartDate; + model.StartTime = input.StartTime; + model.EndDate = input.EndDate; + model.EndTime = input.EndTime; + model.AllDay = input.AllDay; + model.LastModificationTime = DateTime.Now; + + await _dataService.UpdateEventAsync(model, cancellationToken); + return true; + } + + public async Task GetEventByIdAsync(int id, CancellationToken cancellationToken) + { + var model = await _dataService.GetEventByIdAsync(id, cancellationToken); + return model == null ? null : MapToDTO(model); + } + + public async Task DeleteEventAsync(int id, CancellationToken cancellationToken) + { + var model = await _dataService.GetEventForEditAsync(id, cancellationToken); + if (model == null) + return false; + + model.IsDeleted = true; + model.DeletionTime = DateTime.Now; + + await _dataService.UpdateEventAsync(model, cancellationToken); + return true; + } + + public async Task GetEventListAsync(string? search, int page, int pageSize, CancellationToken cancellationToken) + { + var (items, totalCount) = await _dataService.GetEventListPagedAsync(page, pageSize, search, cancellationToken); + + return new PagedEventsResult + { + Rows = items.Select(MapToDTO).ToList(), + TotalCount = totalCount + }; + } + + public async Task> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken) + { + var events = await _dataService.GetEventsForCalendarAsync(start, end, cancellationToken); + return events.Select(MapToDTO); + } + + private static EventDTO MapToDTO(Events model) + { + return new EventDTO + { + Id = model.Id, + Title = model.Title, + Description = model.Description, + Location = model.Location, + RecordID = model.RecordID, + RecordName = model.RecordName, + RecordTargetType = model.RecordTargetType, + ReminderMinutes = model.ReminderMinutes, + EventColor = model.EventColor, + StartDate = model.StartDate, + StartTime = model.StartTime, + EndDate = model.EndDate, + EndTime = model.EndTime, + AllDay = model.AllDay, + CreatedDate = model.CreatedDate, + createdby = model.createdby + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/CommonService.cs b/SeaHaven.Services/Implementation/CommonService.cs new file mode 100644 index 0000000..4cb0aa2 --- /dev/null +++ b/SeaHaven.Services/Implementation/CommonService.cs @@ -0,0 +1,85 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class CommonService : ICommonService + { + private readonly ICommonDataService _dataService; + private readonly UserManager _userManager; + + public CommonService(ICommonDataService dataService, UserManager userManager) + { + _dataService = dataService; + _userManager = userManager; + } + + public async Task GetUsersPagedAsync(string? search, int page, int pageSize, CancellationToken cancellationToken) + { + var (paginatedUsers, totalCount) = await _dataService.GetUsersPagedAsync(page, pageSize, search ?? "", cancellationToken); + + var filteredUsers = new List(); + + foreach (var user in paginatedUsers) + { + var roles = await _userManager.GetRolesAsync(user); + if (!roles.Contains("Admin")) + { + filteredUsers.Add(new UserListItemDTO + { + RoleName = roles.Count() > 0 ? roles.First() : "", + Email = user.Email, + UserName = user.FirstName, + Date = user.CreatedDate?.Date.ToString(), + Status = user.UniqueName, + Id = user.Id + }); + } + } + + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + return new PagedUsersResult + { + Users = filteredUsers, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages + }; + } + + public async Task> GetRolesAsync(CancellationToken cancellationToken) + { + var names = await _dataService.GetRoleNamesAsync(cancellationToken); + return names.Select(name => new RoleListItemDTO { Id = name, Name = name }); + } + + public async Task> GetSiteNamesAsync(CancellationToken cancellationToken) + { + var sites = await _dataService.GetSiteNamesAsync(cancellationToken); + return sites.Select(n => new SiteNameDTO { Id = n.Id, Name = n.Name, Title = n.Title }); + } + + public async Task> GetContactsAsync(CancellationToken cancellationToken) + { + var contacts = await _dataService.GetContactsAsync(cancellationToken); + return contacts.Select(n => new ContactListItemDTO + { + Id = n.Id, + Name = (n.FirstName + " " + n.LastName).Trim(), + Email = n.Email, + Phone = n.PhoneNumber + }); + } + + public async Task> GetCategoriesAsync(CancellationToken cancellationToken) + { + var categories = await _dataService.GetCategoriesAsync(cancellationToken); + return categories.Select(s => new CategoryListItemDTO { Id = s.Id, Name = s.Name }); + } + } +} diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs new file mode 100644 index 0000000..0bec513 --- /dev/null +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -0,0 +1,29 @@ +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class DashboardService : IDashboardService + { + private readonly IDashboardDataService _dataService; + + public DashboardService(IDashboardDataService dataService) + { + _dataService = dataService; + } + + public async Task GetStatsAsync(CancellationToken cancellationToken) + { + var counts = await _dataService.GetWorkOrderCountsAsync(cancellationToken); + + return new DashboardStatsDTO + { + Total = counts.Total, + Open = counts.Open, + NotDispatched = counts.NotDispatched, + Completed = counts.Completed + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/DispatchService.cs b/SeaHaven.Services/Implementation/DispatchService.cs index 2f2d200..680dbca 100644 --- a/SeaHaven.Services/Implementation/DispatchService.cs +++ b/SeaHaven.Services/Implementation/DispatchService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using FluentValidation; +using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -141,6 +142,184 @@ namespace SeaHaven.Services.Implementation return await _dispatchDataService.CountAsync(); } + public async Task GetDispatchListAsync( + string? search, + string? status, + int? vendorId, + DateTime? dateFrom, + DateTime? dateTo, + string? sortBy, + string? sortDir, + int page, + int pageSize, + CancellationToken cancellationToken) + { + var query = new DispatchListQuery + { + Search = search, + Status = status, + VendorId = vendorId, + DateFrom = dateFrom, + DateTo = dateTo, + SortBy = sortBy, + SortDir = sortDir, + Page = page, + PageSize = pageSize + }; + + var result = await _dispatchDataService.GetListAsync(query, cancellationToken); + + return new DispatchListResultDTO + { + Items = result.Items.Select(MapListEntry).ToList(), + Total = result.Total, + Page = result.Page, + PageSize = result.PageSize + }; + } + + public async Task GetDispatchDetailAsync(int id, CancellationToken cancellationToken) + { + var detail = await _dispatchDataService.GetDetailAsync(id, cancellationToken); + return detail == null ? null : MapDetail(detail); + } + + private static DispatchListEntryDTO MapListEntry(DispatchListItemDTO d) + { + return new DispatchListEntryDTO + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + NTEAmount = d.NTEAmount, + Status = d.Status, + DispatchedAt = d.DispatchedAt, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + AcknowledgedAt = d.AcknowledgedAt, + VerifiedAt = d.VerifiedAt, + VendorId = d.VendorId, + VendorName = d.VendorName, + WorkOrderId = d.WorkOrderId, + PrimaryWOInternalNumber = d.PrimaryWOInternalNumber, + PrimaryWOTitle = d.PrimaryWOTitle, + LocationName = d.LocationName, + WorkOrderCount = d.WorkOrderCount, + PendingUpliftCount = d.PendingUpliftCount + }; + } + + private static DispatchDetailResultDTO MapDetail(DispatchDetailDTO d) + { + return new DispatchDetailResultDTO + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + NTEAmount = d.NTEAmount, + Description = d.Description, + Status = d.Status, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + DispatchedAt = d.DispatchedAt, + AcknowledgedAt = d.AcknowledgedAt, + VerifiedAt = d.VerifiedAt, + VerifiedBy = d.VerifiedBy, + EmailSent = d.EmailSent, + WorkOrderId = d.WorkOrderId, + Vendor = d.Vendor == null ? null : new DispatchDetailVendorResultDTO + { + Id = d.Vendor.Id, + CompanyName = d.Vendor.CompanyName, + ContactName = d.Vendor.ContactName, + Email = d.Vendor.Email, + Phone = d.Vendor.Phone, + Address = d.Vendor.Address, + City = d.Vendor.City, + State = d.Vendor.State, + Zip = d.Vendor.Zip, + TradeSpecialties = d.Vendor.TradeSpecialties + }, + PrimaryLocation = d.PrimaryLocation == null ? null : new DispatchDetailLocationResultDTO + { + Id = d.PrimaryLocation.Id, + Name = d.PrimaryLocation.Name, + Address1 = d.PrimaryLocation.Address1, + City = d.PrimaryLocation.City, + State = d.PrimaryLocation.State, + Zip = d.PrimaryLocation.Zip + }, + WorkOrders = d.WorkOrders.Select(w => new DispatchDetailLinkedWorkOrderDTO + { + WorkOrderId = w.WorkOrderId, + InternalWONumber = w.InternalWONumber, + Title = w.Title, + Priority = w.Priority, + Status = w.Status, + LocationName = w.LocationName, + Trade = w.Trade, + SubTrade = w.SubTrade, + Problem = w.Problem + }).ToList(), + Checklist = d.Checklist.Select(c => new DispatchDetailChecklistDTO + { + Id = c.Id, + ItemText = c.ItemText, + IsCompleted = c.IsCompleted, + CompletedBy = c.CompletedBy, + CompletedAt = c.CompletedAt, + SortOrder = c.SortOrder, + WorkOrderId = c.WorkOrderId + }).ToList(), + Signoffs = d.Signoffs.Select(s => new DispatchDetailSignoffDTO + { + Id = s.Id, + SignoffType = s.SignoffType, + Name = s.Name, + Signature = s.Signature, + SignatureMethod = s.SignatureMethod, + SignedAt = s.SignedAt + }).ToList(), + Comments = d.Comments.Select(c => new DispatchDetailCommentDTO + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = c.Commenter, + CommentType = c.CommentType, + RecordType = c.RecordType, + CreatedDate = c.CreatedDate, + UserId = c.UserId, + UserName = c.UserName + }).ToList(), + UpliftRequests = d.UpliftRequests.Select(u => new DispatchDetailUpliftDTO + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + Delta = u.Delta, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + RequestedAt = u.RequestedAt, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByName = u.DecidedByName + }).ToList(), + AuditLog = d.AuditLog.Select(a => new DispatchDetailAuditLogDTO + { + Id = a.Id, + Action = a.Action, + FieldName = a.FieldName, + OldValue = a.OldValue, + NewValue = a.NewValue, + CreatedAt = a.CreatedAt, + UserId = a.UserId, + UserName = a.UserName + }).ToList() + }; + } + private DispatchDTO MapToDTO(Dispatch dispatch) { return new DispatchDTO diff --git a/SeaHaven.Services/Implementation/DropdownOptionsService.cs b/SeaHaven.Services/Implementation/DropdownOptionsService.cs new file mode 100644 index 0000000..6963272 --- /dev/null +++ b/SeaHaven.Services/Implementation/DropdownOptionsService.cs @@ -0,0 +1,88 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class DropdownOptionsService : IDropdownOptionsService + { + private readonly IDropdownOptionsDataService _dataService; + + public DropdownOptionsService(IDropdownOptionsDataService dataService) + { + _dataService = dataService; + } + + public async Task> GetAllAsync(string? category, CancellationToken cancellationToken) + { + var data = await _dataService.GetAllActiveAsync(category, cancellationToken); + return data.Select(MapToDTO); + } + + public async Task> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken) + { + var data = await _dataService.GetByCategoryAsync(category, parent, cancellationToken); + return data.Select(MapToDTO); + } + + public async Task CreateAsync(DropdownOptionDTO option, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(option.Category) || string.IsNullOrWhiteSpace(option.Value)) + throw new InvalidOperationException("Category and Value are required"); + + if (await _dataService.ExistsAsync(option.Category, option.Value, option.ParentValue, cancellationToken)) + throw new InvalidOperationException("This option already exists"); + + var entity = new DropdownOption + { + Id = 0, + Category = option.Category, + Value = option.Value, + ParentValue = option.ParentValue, + SortOrder = option.SortOrder, + IsActive = true + }; + + var created = await _dataService.AddAsync(entity, cancellationToken); + return MapToDTO(created); + } + + public async Task UpdateAsync(int id, DropdownOptionDTO option, CancellationToken cancellationToken) + { + var item = await _dataService.GetByIdAsync(id, cancellationToken); + if (item == null) + throw new KeyNotFoundException("Option not found"); + + item.Value = option.Value; + item.ParentValue = option.ParentValue; + item.SortOrder = option.SortOrder; + item.IsActive = option.IsActive; + + await _dataService.UpdateAsync(item, cancellationToken); + return MapToDTO(item); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken) + { + var item = await _dataService.GetByIdAsync(id, cancellationToken); + if (item == null) + throw new KeyNotFoundException("Option not found"); + + await _dataService.RemoveAsync(item, cancellationToken); + } + + private static DropdownOptionDTO MapToDTO(DropdownOption option) + { + return new DropdownOptionDTO + { + Id = option.Id, + Category = option.Category, + Value = option.Value, + ParentValue = option.ParentValue, + SortOrder = option.SortOrder, + IsActive = option.IsActive + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/FollowUpService.cs b/SeaHaven.Services/Implementation/FollowUpService.cs index e95c517..cfac5bf 100644 --- a/SeaHaven.Services/Implementation/FollowUpService.cs +++ b/SeaHaven.Services/Implementation/FollowUpService.cs @@ -54,7 +54,7 @@ namespace SeaHaven.Services.Implementation public async Task> GetFollowUpsPagedAsync(int page, int pageSize, string? search = null, string? status = null) { var (items, totalCount) = await _followUpDataService.GetPagedAsync(page, pageSize, search); - + var filteredItems = items.AsEnumerable(); if (!string.IsNullOrWhiteSpace(status)) { @@ -148,6 +148,131 @@ namespace SeaHaven.Services.Implementation return await _followUpDataService.CountAsync(); } + public async Task> GetFollowUpListAsync( + int page, + int pageSize, + string? search, + string? status, + CancellationToken cancellationToken) + { + status = string.IsNullOrWhiteSpace(status) ? "Open" : status; + + var items = await _followUpDataService.GetListPagedAsync(page, pageSize, search, status, cancellationToken); + var totalCount = await _followUpDataService.GetListTotalAsync(search, status, cancellationToken); + + return new PagedResult + { + Items = items.Select(MapToListRow).ToList(), + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + public async Task GetFollowUpDetailAsync(int id, CancellationToken cancellationToken) + { + var followUp = await _followUpDataService.GetDetailByIdAsync(id, cancellationToken); + if (followUp == null) + return null; + + var createdByName = await _followUpDataService.GetUserNameAsync(followUp.createdby, cancellationToken); + + return new FollowUpDetailDTO + { + Id = followUp.Id, + Reason = followUp.Reason, + ScheduleDate = followUp.ScheduleDate, + ScheduleStartTime = followUp.ScheduleStartTime, + EmployeeId = followUp.EmployeeId, + WorkOrderId = followUp.WorkOrderId, + AccountId = followUp.AccountId, + LocationId = followUp.LocationId, + AccountName = followUp.Account?.Name ?? "", + AssignedTo = followUp.Employee != null + ? $"{followUp.Employee.FirstName} {followUp.Employee.LastName}" + : "", + LocationName = followUp.Location?.Name ?? "", + WorkOrderNumber = "", + InternalWONumber = "", + Status = followUp.Status, + CreatedDate = followUp.CreatedDate, + CreatedById = followUp.createdby, + CreatedByName = createdByName + }; + } + + public async Task CreateOpenFollowUpAsync(FollowUpCreateRequestDTO dto, string userId, CancellationToken cancellationToken) + { + var followUp = new FollowUps + { + Reason = dto.Reason, + ScheduleDate = dto.ScheduleDate, + ScheduleStartTime = dto.ScheduleStartTime, + EmployeeId = dto.EmployeeId, + WorkOrderId = dto.WorkOrderId, + AccountId = dto.AccountId, + LocationId = dto.LocationId, + Status = "Open", + createdby = userId + }; + + var created = await _followUpDataService.AddAsync(followUp, cancellationToken); + return created.Id; + } + + public async Task UpdateFollowUpFieldsAsync(int id, FollowUpUpdateRequestDTO dto, CancellationToken cancellationToken) + { + var followUp = await _followUpDataService.GetByIdForUpdateAsync(id, cancellationToken); + if (followUp == null) + throw new KeyNotFoundException($"Follow-Up with ID {id} not found"); + + followUp.Reason = dto.Reason; + followUp.ScheduleDate = dto.ScheduleDate; + followUp.ScheduleStartTime = dto.ScheduleStartTime; + followUp.EmployeeId = dto.EmployeeId; + followUp.WorkOrderId = dto.WorkOrderId; + followUp.AccountId = dto.AccountId; + followUp.LocationId = dto.LocationId; + + await _followUpDataService.UpdateAsync(followUp, cancellationToken); + } + + public Task SetFollowUpStatusAsync(int id, string status, CancellationToken cancellationToken) + { + return _followUpDataService.SetStatusAsync(id, status, cancellationToken); + } + + public Task RemoveFollowUpAsync(int id, CancellationToken cancellationToken) + { + return _followUpDataService.RemoveByIdAsync(id, cancellationToken); + } + + private static FollowUpListRowDTO MapToListRow(FollowUps followUp) + { + return new FollowUpListRowDTO + { + Id = followUp.Id, + Reason = followUp.Reason, + ScheduleDate = followUp.ScheduleDate, + ScheduleStartTime = followUp.ScheduleStartTime, + EmployeeId = followUp.EmployeeId, + WorkOrderId = followUp.WorkOrderId, + AccountId = followUp.AccountId, + LocationId = followUp.LocationId, + AccountName = followUp.Account?.Name ?? "", + AssignedTo = followUp.Employee != null + ? $"{followUp.Employee.FirstName} {followUp.Employee.LastName}" + : "", + LocationName = followUp.Location?.Name ?? "", + WorkOrderNumber = "", + InternalWONumber = "", + Status = followUp.Status, + CreatedDate = followUp.CreatedDate, + CreatedById = null, + CreatedByName = "" + }; + } + private FollowUpDTO MapToDTO(FollowUps followUp) { return new FollowUpDTO diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index accf6ba..da98b66 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -153,6 +153,70 @@ namespace SeaHaven.Services.Implementation return await _locationDataService.CountAsync(); } + public async Task> GetLocationListPagedAsync( + int page, + int pageSize, + string? search, + CancellationToken cancellationToken) + { + var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, cancellationToken); + return new PagedResult + { + Items = items.Select(MapToDTO).ToList(), + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + public async Task GetLocationDetailAsync(int id, CancellationToken cancellationToken) + { + var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken); + return location == null ? null : MapToDTO(location); + } + + public async Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken) + { + var location = new Locations + { + Name = request.Name, + Title = request.Title, + Address1 = request.Address, + City = request.City, + State = request.State, + Zip = request.ZipCode, + PhoneNumber = request.Phone, + Email = request.ContactEmail, + Status = request.Status + }; + + await _locationDataService.AddAsync(location, cancellationToken); + } + + public async Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken) + { + var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken); + if (location == null) + throw new KeyNotFoundException($"Location with ID {id} not found"); + + location.Name = request.Name; + location.Title = request.Title; + location.Address1 = request.Address; + location.City = request.City; + location.State = request.State; + location.Zip = request.ZipCode; + location.PhoneNumber = request.Phone; + location.Email = request.ContactEmail; + location.Status = request.Status; + + await _locationDataService.UpdateAsync(location, cancellationToken); + } + + public Task DeleteLocationByIdAsync(int id, CancellationToken cancellationToken) + { + return _locationDataService.DeleteByIdAsync(id, cancellationToken); + } + // Private helper methods private LocationDTO MapToDTO(Locations location) { diff --git a/SeaHaven.Services/Implementation/QuotesService.cs b/SeaHaven.Services/Implementation/QuotesService.cs new file mode 100644 index 0000000..a82dd9c --- /dev/null +++ b/SeaHaven.Services/Implementation/QuotesService.cs @@ -0,0 +1,191 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class QuotesService : IQuotesService + { + private readonly IQuotesDataService _quotesData; + private readonly IWorkOrderDataService _workOrderData; + + public QuotesService(IQuotesDataService quotesData, IWorkOrderDataService workOrderData) + { + _quotesData = quotesData; + _workOrderData = workOrderData; + } + + public async Task CreateQuoteAsync(CreateQuoteInput input, CancellationToken cancellationToken) + { + var rnd = new Random(); + var id = rnd.Next(1, 99999); + + var quote = new Quotes + { + QuotId = id.ToString(), + ContactId = input.ContactId, + PO = input.PO, + TT = input.TT, + ProjectName = input.ProjectName, + Date = input.Date, + WorkorderId = input.WorkorderId, + SiteName = input.SiteName, + SiteId = input.SiteId, + BillingAddress = input.BillingAddress, + LineItems = new List() + }; + + if (input.LineItems != null && input.LineItems.Any()) + { + foreach (var li in input.LineItems) + { + quote.LineItems.Add(new QuotesLineItems + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }); + } + } + + await _quotesData.AddAsync(quote); + } + + public async Task UpdateQuoteAsync(UpdateQuoteInput input, CancellationToken cancellationToken) + { + var quote = await _quotesData.GetByIdAsync(input.Id); + if (quote == null) + return false; + + quote.QuotId = input.QuotId; + quote.ContactId = input.ContactId; + quote.PO = input.PO; + quote.TT = input.TT; + quote.ProjectName = input.ProjectName; + quote.Date = input.Date; + quote.WorkorderId = input.WorkorderId; + quote.SiteName = input.SiteName; + quote.SiteId = input.SiteId; + quote.BillingAddress = input.BillingAddress; + + await _quotesData.DeleteLineItemsByQuoteIdAsync(input.Id, cancellationToken); + + quote.LineItems ??= new List(); + + if (input.LineItems != null && input.LineItems.Any()) + { + foreach (var li in input.LineItems) + { + quote.LineItems.Add(new QuotesLineItems + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }); + } + } + + await _quotesData.UpdateAsync(quote); + return true; + } + + public async Task DeleteQuoteAsync(int id, CancellationToken cancellationToken) + { + var quote = await _quotesData.GetByIdAsync(id); + if (quote == null) + return false; + + await _quotesData.DeleteLineItemsByQuoteIdAsync(id, cancellationToken); + await _quotesData.DeleteAsync(id); + return true; + } + + public async Task GetQuotesPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken) + { + var (items, totalCount) = await _quotesData.GetQuotesPagedAsync(page, pageSize, search, cancellationToken); + + return new PagedQuotesResult + { + Items = items.Select(item => new QuoteListItemDTO + { + Id = item.Id, + QuoteId = item.QuoteId, + WorkOrder = item.WorkOrder, + ProjectName = item.ProjectName, + TotalAmount = FormatAmount(item.TotalAmount), + SiteName = item.SiteName, + SiteId = item.SiteId, + PO = item.PO, + TT = item.TT, + BillingAddress = item.BillingAddress, + Status = item.Status, + ContactId = item.ContactId, + Date = item.Date, + Lineitems = item.Lineitems.Select(MapLineItem).ToList() + }).ToList(), + TotalCount = totalCount + }; + } + + public async Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken) + { + var items = await _quotesData.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken); + return items.Select(item => new WorkOrderQuoteDetailDTO + { + Id = item.Id, + PO = item.PO, + TT = item.TT, + Title = item.Title, + Name = item.Name + }).ToList(); + } + + public async Task GetQuoteDetailsByIdAsync( + int id, CancellationToken cancellationToken) + { + var item = await _quotesData.GetQuoteDetailsByIdAsync(id, cancellationToken); + return item == null ? null : new QuoteDetailDTO + { + Id = item.Id, + QuotId = item.QuotId, + Date = item.Date, + Name = item.Name, + PO = item.PO, + TT = item.TT, + SiteName = item.SiteName, + ProjectName = item.ProjectName, + SiteId = item.SiteId, + BillingAddress = item.BillingAddress, + ShippingAddress = item.ShippingAddress, + TotalAmount = FormatAmount(item.TotalAmount), + Lineitems = item.Lineitems.Select(MapLineItem).ToList() + }; + } + + public async Task GetReportingAsync(CancellationToken cancellationToken) + { + return new QuoteReportingData + { + TotalCreatedWorkOrder = await _workOrderData.CountAsync(), + TotalCompleted = await _workOrderData.CountByStatusAsync("Done"), + TotalSev1 = await _workOrderData.CountByPriorityAsync("Sev 1"), + TotalSev2 = await _workOrderData.CountByPriorityAsync("Sev 2"), + TotalSev3 = await _workOrderData.CountByPriorityAsync("Sev 3"), + TotalSev4 = await _workOrderData.CountByPriorityAsync("Sev 4"), + TotalSev5 = await _workOrderData.CountByPriorityAsync("Sev 5") + }; + } + + private static QuoteLineItemDTO MapLineItem( + SeaHaven.DataServices.Models.QuoteLineItemReadModel item) => new() + { + Descriptions = item.Descriptions, + Lineitem = item.Lineitem, + LineitemNumber = item.LineitemNumber + }; + + private static string FormatAmount(decimal amount) => $"${amount}"; + } +} diff --git a/SeaHaven.Services/Implementation/SettingsService.cs b/SeaHaven.Services/Implementation/SettingsService.cs new file mode 100644 index 0000000..c513f88 --- /dev/null +++ b/SeaHaven.Services/Implementation/SettingsService.cs @@ -0,0 +1,166 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class SettingsService : ISettingsService + { + private readonly ISettingsDataService _dataService; + private readonly UserManager _userManager; + + public SettingsService(ISettingsDataService dataService, UserManager userManager) + { + _dataService = dataService; + _userManager = userManager; + } + + public async Task AddCategoryAsync(string? name, CancellationToken cancellationToken) + { + await _dataService.AddCategoryAsync(name ?? "", cancellationToken); + } + + public Task UpdateCategoryAsync(int? id, string? name, CancellationToken cancellationToken) + { + return _dataService.UpdateCategoryAsync(id ?? 0, name ?? "", cancellationToken); + } + + public Task DeleteCategoryAsync(int id, CancellationToken cancellationToken) + { + return _dataService.DeleteCategoryCascadeAsync(id, cancellationToken); + } + + public async Task<(IReadOnlyList Items, int Total)> GetCategoryListAsync(int page, int pageSize, CancellationToken cancellationToken) + { + var (items, total) = await _dataService.GetCategoryListPagedAsync(page, pageSize, cancellationToken); + return (items.Select(c => new SettingsCategoryDTO { Id = c.Id, Name = c.Name }).ToList(), total); + } + + public async Task<(IReadOnlyList Items, int Total)> GetTemplatesAsync(int page, int pageSize, CancellationToken cancellationToken) + { + var (items, total) = await _dataService.GetTemplatesPagedAsync(page, pageSize, cancellationToken); + return (items.Select(MapTemplate).ToList(), total); + } + + public async Task<(IReadOnlyList Users, int Total)> GetUsersAsync(string? search, int page, int pageSize, CancellationToken cancellationToken) + { + var (users, total) = await _dataService.GetUsersPagedAsync(search, page, pageSize, cancellationToken); + + var filtered = new List(); + foreach (var user in users) + { + var roles = await _userManager.GetRolesAsync(user); + if (!roles.Contains("Admin")) + { + filtered.Add(new SettingsUserDTO + { + RoleName = roles.Count() > 0 ? roles.First() : "", + Email = user.Email, + UserName = user.FirstName, + Date = user.CreatedDate?.Date.ToString(), + Status = user.UniqueName, + Id = user.Id + }); + } + } + + return (filtered, total); + } + + public async Task> GetRolesAsync(CancellationToken cancellationToken) + { + var names = await _dataService.GetRoleNamesAsync(cancellationToken); + var roles = new List(); + foreach (var name in names) + { + if (name == "Admin") + { + roles.Add(new RoleDTO { Name = name, Description = "Full Access" }); + } + else if (name == "Supervisor") + { + roles.Add(new RoleDTO { Name = name, Description = "Access to Team's Work Orders Only" }); + } + else if (name == "Manager") + { + roles.Add(new RoleDTO { Name = name, Description = "Full access accept quotes" }); + } + else if (name == "User") + { + roles.Add(new RoleDTO { Name = name, Description = "Access to Assigned Work Orders Only" }); + } + } + return roles; + } + + private static SettingsTemplateDTO MapTemplate(WorkOrder w) + { + return new SettingsTemplateDTO + { + Id = w.Id, + IsDeleted = w.IsDeleted, + createdby = w.createdby, + DeleterUserId = w.DeleterUserId, + DeletionTime = w.DeletionTime, + CreatedDate = w.CreatedDate, + LastModificationTime = w.LastModificationTime, + LastModifierUserId = w.LastModifierUserId, + InternalWONumber = w.InternalWONumber, + ExternalWorkOrderId = w.ExternalWorkOrderId, + WorkerOrderNumber = w.WorkerOrderNumber, + WorkerOrderTitle = w.WorkerOrderTitle, + Description = w.Description, + Customer = w.Customer, + SiteCode = w.SiteCode, + Building = w.Building, + Severity = w.Severity, + DueDate = w.DueDate, + AssignTo = w.AssignTo, + LocationId = w.LocationId, + LifecycleStatus = w.LifecycleStatus, + LegacyStatus = w.LegacyStatus, + WorkOrderType = w.WorkOrderType, + Service = w.Service, + PrimaryDispatchId = w.PrimaryDispatchId, + ScheduledDate = w.ScheduledDate, + ScheduledStart = w.ScheduledStart, + ScheduledEnd = w.ScheduledEnd, + TargetWeek = w.TargetWeek, + ScheduleWeekOnly = w.ScheduleWeekOnly, + OriginalWeek = w.OriginalWeek, + OriginalDate = w.OriginalDate, + RescheduleCount = w.RescheduleCount, + CarriedOver = w.CarriedOver, + OperationalFlags = w.OperationalFlags, + DocStatus = w.DocStatus, + AvettaTask = w.AvettaTask, + AssignDate = w.AssignDate, + DateReported = w.DateReported, + SourceEmailS3Key = w.SourceEmailS3Key, + Problem = w.Problem, + Trade = w.Trade, + SubTrade = w.SubTrade, + VendorNTE = w.VendorNTE, + CompletedDate = w.CompletedDate, + Source = w.Source, + istemplate = w.istemplate, + PO = w.PO, + TT = w.TT, + Priority = w.Priority, + Status = w.Status, + Attachments = w.Attachments, + BeforPhotoIssue = w.BeforPhotoIssue, + BeforPhotoAttachment = w.BeforPhotoAttachment, + AfterPhotoIssue = w.AfterPhotoIssue, + AfterPhotoAttachment = w.AfterPhotoAttachment, + SignOffName = w.SignOffName, + SignOffAttachment = w.SignOffAttachment, + SignOffSignature = w.SignOffSignature, + RowVersion = w.RowVersion + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/SyncService.cs b/SeaHaven.Services/Implementation/SyncService.cs new file mode 100644 index 0000000..f9268c6 --- /dev/null +++ b/SeaHaven.Services/Implementation/SyncService.cs @@ -0,0 +1,304 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class SyncService : ISyncService + { + private readonly ISyncDataService _data; + private readonly ISyncExternalSource _external; + + public SyncService(ISyncDataService data, ISyncExternalSource external) + { + _data = data; + _external = external; + } + + public async Task SyncWorkOrdersAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var updated = 0; + + var lastInternalWO = await _data.GetLastInternalWONumberAsync(cancellationToken); + int nextInternal = 10000001; + if (lastInternalWO != null && int.TryParse(lastInternalWO, out var parsed)) + nextInternal = parsed + 1; + + await foreach (var item in _external.ScanWorkOrdersAsync(cancellationToken)) + { + var externalId = Get(item, "work_order_id"); + if (string.IsNullOrEmpty(externalId)) continue; + + var existing = await _data.GetWorkOrderByExternalIdAsync(externalId, cancellationToken); + + var locationId = await _data.ResolveLocationAsync( + Get(item, "site_code"), + Get(item, "building"), + Get(item, "address"), + cancellationToken); + + if (existing == null) + { + var wo = new WorkOrder + { + InternalWONumber = (nextInternal++).ToString("D8"), + ExternalWorkOrderId = externalId, + WorkerOrderNumber = externalId, + WorkerOrderTitle = Get(item, "description"), + Description = Get(item, "description"), + Status = MapStatus(Get(item, "wo_status")), + Priority = MapSeverityToPriority(Get(item, "severity")), + Severity = Get(item, "severity"), + Customer = Get(item, "customer"), + SiteCode = Get(item, "site_code"), + Building = Get(item, "building"), + LocationId = locationId, + DueDate = ParseDate(Get(item, "due_date")), + DateReported = ParseDate(Get(item, "date_reported")), + ScheduledStart = ParseDate(Get(item, "scheduled_start")), + AssignTo = null, + SourceEmailS3Key = Get(item, "source_email_s3_key"), + CreatedDate = ParseDate(Get(item, "created_at")) ?? DateTime.UtcNow, + istemplate = false + }; + _data.EnqueueWorkOrder(wo); + created++; + } + else + { + existing.WorkerOrderTitle = Get(item, "description") ?? existing.WorkerOrderTitle; + existing.Description = Get(item, "description") ?? existing.Description; + existing.Status = MapStatus(Get(item, "wo_status")) ?? existing.Status; + existing.Priority = MapSeverityToPriority(Get(item, "severity")) ?? existing.Priority; + existing.Severity = Get(item, "severity") ?? existing.Severity; + existing.SiteCode = Get(item, "site_code") ?? existing.SiteCode; + existing.Building = Get(item, "building") ?? existing.Building; + existing.LocationId = locationId ?? existing.LocationId; + existing.DueDate = ParseDate(Get(item, "due_date")) ?? existing.DueDate; + existing.DateReported = ParseDate(Get(item, "date_reported")) ?? existing.DateReported; + existing.ScheduledStart = ParseDate(Get(item, "scheduled_start")) ?? existing.ScheduledStart; + existing.SourceEmailS3Key = Get(item, "source_email_s3_key") ?? existing.SourceEmailS3Key; + updated++; + } + + synced++; + + if (synced % 100 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + + return new SyncWorkOrdersResult + { + Synced = synced, + Created = created, + Updated = updated, + LocationsCreated = await _data.GetLocationCountAsync(cancellationToken) + }; + } + + public async Task SyncCommentsAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var skipped = 0; + + await foreach (var item in _external.ScanCommentsAsync(cancellationToken)) + { + var externalCommentId = Get(item, "comment_id"); + var externalWoId = Get(item, "work_order_id"); + + if (string.IsNullOrEmpty(externalCommentId) || string.IsNullOrEmpty(externalWoId)) + continue; + + var alreadyExists = await _data.CommentExistsByExternalIdAsync(externalCommentId, cancellationToken); + + if (alreadyExists) + { + skipped++; + synced++; + continue; + } + + var workOrder = await _data.GetWorkOrderByExternalIdAsync(externalWoId, cancellationToken); + + if (workOrder == null) + { + skipped++; + continue; + } + + var comment = new Comments + { + ExternalCommentId = externalCommentId, + WorkerOrderId = workOrder.Id, + Commenttext = Get(item, "text"), + Commenter = Get(item, "commenter"), + RecordType = Get(item, "record_type"), + CommentType = "customer", + CreatedDate = ParseDate(Get(item, "created_at")) ?? DateTime.UtcNow, + }; + + _data.EnqueueComment(comment); + created++; + synced++; + + if (synced % 100 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + + return new SyncCommentsResult { Synced = synced, Created = created, Skipped = skipped }; + } + + public async Task BackfillInternalWONumbersAsync(CancellationToken cancellationToken) + { + var wosMissing = await _data.GetWorkOrdersMissingInternalNumberAsync(cancellationToken); + + if (wosMissing.Count == 0) + return new BackfillResult { Updated = 0 }; + + var lastWO = await _data.GetLastInternalWONumberAsync(cancellationToken); + + int next = 10000001; + if (lastWO != null && int.TryParse(lastWO, out var lastNum)) + next = lastNum + 1; + + foreach (var wo in wosMissing) + { + wo.InternalWONumber = next.ToString("D8"); + next++; + } + + await _data.SaveChangesAsync(cancellationToken); + return new BackfillResult { Updated = wosMissing.Count }; + } + + public async Task BackfillDispatchNumbersAsync(CancellationToken cancellationToken) + { + var missing = await _data.GetDispatchesMissingNumberAsync(cancellationToken); + if (missing.Count == 0) return new BackfillResult { Updated = 0 }; + + int next = 1; + var last = await _data.GetLastDispatchNumberAsync(cancellationToken); + if (last != null && last.StartsWith("DSP-") && int.TryParse(last.Substring(4), out var n)) + next = n + 1; + + foreach (var d in missing) + { + d.DispatchNumber = $"DSP-{next:D5}"; + next++; + } + await _data.SaveChangesAsync(cancellationToken); + return new BackfillResult { Updated = missing.Count }; + } + + public async Task SyncVendorRepliesAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var skipped = 0; + + await foreach (var item in _external.ScanVendorRepliesAsync(cancellationToken)) + { + var replyId = Get(item, "reply_id"); + var woNumber = Get(item, "internal_wo_number"); + var dispatchNumber = Get(item, "dispatch_number"); + + if (string.IsNullOrEmpty(replyId) || string.IsNullOrEmpty(woNumber)) + continue; + + var alreadyExists = await _data.CommentExistsByExternalIdAsync(replyId, cancellationToken); + if (alreadyExists) { skipped++; synced++; continue; } + + var workOrder = await _data.GetWorkOrderByInternalNumberAsync(woNumber, cancellationToken); + if (workOrder == null) { skipped++; continue; } + + int? dispatchId = null; + if (!string.IsNullOrWhiteSpace(dispatchNumber)) + { + var dispatch = await _data.GetDispatchByNumberAndWorkOrderIdAsync(dispatchNumber, workOrder.Id, cancellationToken); + dispatchId = dispatch?.Id; + } + + var comment = new Comments + { + ExternalCommentId = replyId, + WorkerOrderId = workOrder.Id, + DispatchId = dispatchId, + Commenttext = Get(item, "reply_text"), + Commenter = Get(item, "sender_email"), + CommentType = "vendor", + RecordType = "vendor_reply", + CreatedDate = ParseDate(Get(item, "received_at")) ?? DateTime.UtcNow, + }; + + _data.EnqueueComment(comment); + created++; + synced++; + + if (synced % 50 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + return new SyncCommentsResult { Synced = synced, Created = created, Skipped = skipped }; + } + + public async Task BackfillCommentTypesAsync(CancellationToken cancellationToken) + { + var updated = await _data.ExecuteBackfillCommentTypesAsync(cancellationToken); + return new BackfillResult { Updated = updated }; + } + + public async Task SyncAllAsync(CancellationToken cancellationToken) + { + var woResult = await SyncWorkOrdersAsync(cancellationToken); + var commentResult = await SyncCommentsAsync(cancellationToken); + + return new SyncAllResult + { + WorkOrders = woResult, + Comments = commentResult + }; + } + + private static string? Get(Dictionary item, string key) + { + return item.TryGetValue(key, out var val) ? val : null; + } + + private static DateTime? ParseDate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (DateTime.TryParse(value, out var dt)) return dt; + return null; + } + + private static string? MapStatus(string? dynamoStatus) + { + return dynamoStatus switch + { + "new" => "Open", + "assigned" => "Open", + "in_progress" => "In Progress", + "on_hold" => "On Hold", + "completed" => "Done", + "cancelled" => "Cancelled", + "unknown" => "Open", + _ => "Open" + }; + } + + private static string? MapSeverityToPriority(string? severity) + { + if (string.IsNullOrWhiteSpace(severity)) return null; + return $"Sev {severity}"; + } + } +} diff --git a/SeaHaven.Services/Implementation/TaskListTemplateService.cs b/SeaHaven.Services/Implementation/TaskListTemplateService.cs new file mode 100644 index 0000000..294e4d7 --- /dev/null +++ b/SeaHaven.Services/Implementation/TaskListTemplateService.cs @@ -0,0 +1,92 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class TaskListTemplateService : ITaskListTemplateService + { + private readonly ITaskListTemplateDataService _dataService; + + public TaskListTemplateService(ITaskListTemplateDataService dataService) + { + _dataService = dataService; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken) + { + var templates = await _dataService.GetActiveTemplatesWithItemsAsync(cancellationToken); + return templates.Select(MapToDTO); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken) + { + var template = await _dataService.GetTemplateWithItemsReadOnlyAsync(id, cancellationToken); + return template == null ? null : MapToDTO(template); + } + + public async Task CreateAsync(TaskListTemplateInput input, CancellationToken cancellationToken) + { + var template = new TaskListTemplate + { + Name = input.Name, + Description = input.Description, + IsActive = input.IsActive, + Items = input.Items.Select((item, idx) => new TaskListTemplateItem + { + ItemText = item.ItemText, + SortOrder = item.SortOrder > 0 ? item.SortOrder : idx + }).ToList() + }; + + var created = await _dataService.AddTemplateAsync(template, cancellationToken); + return created.Id; + } + + public async Task UpdateAsync(int id, TaskListTemplateInput input, CancellationToken cancellationToken) + { + var template = await _dataService.GetTemplateWithItemsForUpdateAsync(id, cancellationToken); + if (template == null) + return false; + + template.Name = input.Name; + template.Description = input.Description; + template.IsActive = input.IsActive; + + var newItems = input.Items.Select((item, idx) => new TaskListTemplateItem + { + TaskListTemplateId = id, + ItemText = item.ItemText, + SortOrder = item.SortOrder > 0 ? item.SortOrder : idx + }).ToList(); + + await _dataService.ReplaceTemplateItemsAsync(template, newItems, cancellationToken); + return true; + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken) + { + return await _dataService.DeactivateTemplateAsync(id, cancellationToken); + } + + private static TaskListTemplateDTO MapToDTO(TaskListTemplate template) + { + return new TaskListTemplateDTO + { + Id = template.Id, + Name = template.Name, + Description = template.Description, + IsActive = template.IsActive, + Items = (template.Items ?? new List()) + .OrderBy(i => i.SortOrder) + .Select(i => new TaskListTemplateItemDTO + { + Id = i.Id, + ItemText = i.ItemText, + SortOrder = i.SortOrder + }).ToList() + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs new file mode 100644 index 0000000..051dd69 --- /dev/null +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -0,0 +1,178 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace SeaHaven.Services.Implementation +{ + public class UpliftService : IUpliftService + { + private readonly IUpliftDataService _upliftData; + private readonly IDispatchDataService _dispatchData; + private readonly ApprovalsOptions _approvalsOptions; + + public UpliftService(IUpliftDataService upliftData, IDispatchDataService dispatchData, IOptions approvalsOptions) + { + _upliftData = upliftData; + _dispatchData = dispatchData; + _approvalsOptions = approvalsOptions.Value; + } + + public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) + { + var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken); + + var mapped = items.Select(r => new UpliftListItemDTO + { + Id = r.Id, + DispatchId = r.DispatchId, + DispatchNumber = r.DispatchNumber, + PONumber = r.PONumber, + WorkOrderId = r.WorkOrderId, + VendorCompanyName = r.VendorCompanyName, + CurrentNTE = r.CurrentNTE, + RequestedNTE = r.RequestedNTE, + Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + VendorReason = r.VendorReason, + RequiredTier = r.RequiredTier, + Status = r.Status, + RequestedAt = r.CreatedDate, + DecidedAt = r.DecidedAt, + DecisionNote = r.DecisionNote, + CanDecide = UserCanApprove(user, r.RequiredTier) + }).ToList(); + + return new UpliftListResultDTO + { + Total = total, + Page = page, + PageSize = pageSize, + Items = mapped + }; + } + + public async Task> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken) + { + var rows = await _upliftData.GetForDispatchAsync(dispatchId, cancellationToken); + + return rows.Select(r => new UpliftForDispatchDTO + { + Id = r.Id, + DispatchId = r.DispatchId, + CurrentNTE = r.CurrentNTE, + RequestedNTE = r.RequestedNTE, + Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + VendorReason = r.VendorReason, + Status = r.Status, + RequiredTier = r.RequiredTier, + RequestedByVendorName = r.RequestedByVendorName, + RequestedAt = r.CreatedDate, + DecidedAt = r.DecidedAt, + DecisionNote = r.DecisionNote, + DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), + CanDecide = UserCanApprove(user, r.RequiredTier) + }).ToList(); + } + + public async Task ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) + { + var req = await _upliftData.GetByIdAsync(id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + if (req.Status != "Pending") throw new InvalidOperationException($"Cannot approve a '{req.Status}' request"); + if (!UserCanApprove(user, req.RequiredTier)) + throw new UpliftForbiddenException($"Approval requires a Tier {req.RequiredTier} role"); + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch"); + + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var now = DateTime.UtcNow; + var oldNTE = dispatch.NTEAmount ?? 0m; + + dispatch.NTEAmount = req.RequestedNTE; + dispatch.LastModificationTime = now; + + req.Status = "Approved"; + req.DecidedAt = now; + req.DecidedByUserId = userId; + req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim(); + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", + OldValue = $"${oldNTE:F2}", + NewValue = $"${req.RequestedNTE:F2}", + Action = "uplift_approved", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new UpliftApproveResultDTO { Id = req.Id, Status = req.Status, NTEAmount = dispatch.NTEAmount }; + } + + public async Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(note)) + throw new InvalidOperationException("A note is required when denying"); + + var req = await _upliftData.GetByIdAsync(id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + if (req.Status != "Pending") throw new InvalidOperationException($"Cannot deny a '{req.Status}' request"); + if (!UserCanApprove(user, req.RequiredTier)) + throw new UpliftForbiddenException($"Decision requires a Tier {req.RequiredTier} role"); + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var now = DateTime.UtcNow; + + req.Status = "Denied"; + req.DecidedAt = now; + req.DecidedByUserId = userId; + req.DecisionNote = note!.Trim(); + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch?.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", + OldValue = "Pending", + NewValue = "Denied", + Action = "uplift_denied", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new UpliftDenyResultDTO { Id = req.Id, Status = req.Status }; + } + + public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier); + + private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) + { + var roles = RolesForTier(requiredTier); + foreach (var r in roles) + { + if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true; + } + return false; + } + + private string[] RolesForTier(int requiredTier) => requiredTier switch + { + 1 => _approvalsOptions.Tier1Roles, + 2 => _approvalsOptions.Tier2Roles, + _ => Array.Empty() + }; + } +} diff --git a/SeaHaven.Services/Implementation/UserService.cs b/SeaHaven.Services/Implementation/UserService.cs new file mode 100644 index 0000000..454e619 --- /dev/null +++ b/SeaHaven.Services/Implementation/UserService.cs @@ -0,0 +1,201 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Cryptography; + +namespace SeaHaven.Services.Implementation +{ + public class UserService : IUserService + { + private readonly UserManager _userManager; + private readonly IUserDataService _userDataService; + private readonly IEmailSender _emailSender; + + public UserService( + UserManager userManager, + IUserDataService userDataService, + IEmailSender emailSender) + { + _userManager = userManager; + _userDataService = userDataService; + _emailSender = emailSender; + } + + public async Task> GetUsersAsync(CancellationToken cancellationToken) + { + var users = await _userDataService.GetAllForListAsync(cancellationToken); + return users.Select(s => new UserListRowDTO + { + RoleName = s.RoleName, + Email = s.Email, + Name = s.Name, + Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "", + Status = s.Status, + Id = s.Id + }); + } + + public async Task AddUserAsync(AddUserRequestDTO dto, CancellationToken cancellationToken) + { + var model = new ApplicationUser + { + UserName = dto.Email, + FirstName = dto.Name, + Email = dto.Email, + }; + + var exist = await _userDataService.GetByIdAsync(model.Id); + if (exist == null) + { + model.EmailConfirmed = true; + model.UserName = model.Email; + model.CreatedDate = DateTime.Now; + model.UniqueName = "Active"; + model.PhoneNumber = dto.Role; + + var password = GenerateRandomPassword(); + + var result = await _userManager.CreateAsync(model, password); + if (result.Succeeded) + { + await _userManager.AddToRoleAsync(model, dto.Role ?? ""); + } + else + { + return new AddUserOutcomeDTO { Success = false, Error = result.Errors.FirstOrDefault()?.Description ?? "error" }; + } + var domain = "http://shoc-ui-app.s3-website-us-east-1.amazonaws.com/#"; + string subject = "Login Information"; + string greeting = $"Dear {model.FirstName},\n\n"; + string loginLink = $"Click here to login:Login\n"; + string passwordInfo = $"Your password is: {password}\n\n"; + string body = $"{greeting}\n\n{loginLink}\n\n{passwordInfo}"; + await _emailSender.SendEmailAsync(model?.Email ?? "", subject, body); + return new AddUserOutcomeDTO { Success = true }; + } + else + { + var exist1 = await _userDataService.GetForEditAsync(model.Id, cancellationToken); + if (exist1 == null) + return new AddUserOutcomeDTO { Success = false, Error = "User not found" }; + + var existingRole = await _userManager.GetRolesAsync(exist1); + + if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber) + { + await _userManager.RemoveFromRolesAsync(exist1, existingRole); + } + + exist1.FirstName = model.FirstName; + exist1.LastName = model.LastName; + exist1.Contact = model.Contact; + exist1.PhoneNumber = model.PhoneNumber; + + await _userDataService.UpdateUserAsync(exist1, cancellationToken); + + await _userManager.AddToRoleAsync(exist1, dto.Role ?? ""); + await _userManager.UpdateAsync(exist1); + return new AddUserOutcomeDTO { Success = true }; + } + } + + public async Task EditUserAsync(EditUserRequestDTO dto, CancellationToken cancellationToken) + { + var exist = await _userDataService.GetForEditAsync(dto.Id ?? "", cancellationToken); + if (exist == null) + return false; + + exist.FirstName = dto.Name; + if (string.IsNullOrWhiteSpace(dto.Email)) + throw new ArgumentException("Email is required.", nameof(dto)); + + exist.EmailConfirmed = true; + exist.UserName = dto.Email; + exist.Email = dto.Email; + exist.NormalizedEmail = dto.Email.ToUpperInvariant(); + exist.NormalizedUserName = dto.Email.ToUpperInvariant(); + exist.CreatedDate = DateTime.Now; + exist.UniqueName = "Active"; + exist.PhoneNumber = dto.Role; + + var existingRole = await _userManager.GetRolesAsync(exist); + if (existingRole != null && existingRole.Any()) + { + await _userManager.RemoveFromRolesAsync(exist, existingRole); + } + await _userManager.AddToRoleAsync(exist, dto.Role ?? ""); + + await _userDataService.UpdateUserAsync(exist, cancellationToken); + return true; + } + + public async Task DeleteUserAsync(string id, CancellationToken cancellationToken) + { + var data = await _userDataService.GetForEditAsync(id, cancellationToken); + if (data == null) + return false; + + await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken); + return true; + } + + public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken) + { + var exist = await _userDataService.GetForEditAsync(userId, cancellationToken); + if (exist != null) + { + exist.IsDeleted = true; + await _userDataService.UpdateUserAsync(exist, cancellationToken); + } + } + + public async Task> GetUserProfileAsync(string userId, CancellationToken cancellationToken) + { + var user = await _userDataService.GetProfileAsync(userId, cancellationToken); + if (user == null) + return Array.Empty(); + + return new List + { + new UserProfileRowDTO + { + Id = user.Id, + Name = user.FirstName, + AddedDate = user.CreatedDate, + Email = user.Email, + Contact = user.Contact + } + }; + } + + private static string GenerateRandomPassword(int length = 8) + { + const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?"; + const string capitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const string digits = "1234567890"; + const string symbols = "!@#$%^&*()_-+=<>?"; + + var passwordChars = new char[length]; + + passwordChars[0] = capitalLetters[RandomNumberGenerator.GetInt32(capitalLetters.Length)]; + passwordChars[1] = digits[RandomNumberGenerator.GetInt32(digits.Length)]; + passwordChars[2] = symbols[RandomNumberGenerator.GetInt32(symbols.Length)]; + passwordChars[3] = "abcdefghijklmnopqrstuvwxyz"[RandomNumberGenerator.GetInt32(26)]; + + for (int i = 4; i < length; i++) + { + passwordChars[i] = validChars[RandomNumberGenerator.GetInt32(validChars.Length)]; + } + + for (int i = passwordChars.Length - 1; i > 0; i--) + { + int j = RandomNumberGenerator.GetInt32(i + 1); + (passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]); + } + + return new string(passwordChars); + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorOperationsService.cs b/SeaHaven.Services/Implementation/VendorOperationsService.cs new file mode 100644 index 0000000..92d5779 --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorOperationsService.cs @@ -0,0 +1,469 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class VendorOperationsService : IVendorOperationsService + { + private static readonly string[] ValidAvailabilityStatuses = { "Available", "Unavailable", "Unknown" }; + private static readonly string[] InvoiceStatuses = { "Not Submitted", "Processing", "Confirmed", "Rejected" }; + private static readonly string[] PaymentStatuses = { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" }; + + private readonly IVendorOperationsDataService _data; + private readonly TimeProvider _timeProvider; + + public VendorOperationsService(IVendorOperationsDataService data, TimeProvider timeProvider) + { + _data = data; + _timeProvider = timeProvider; + } + + public DateTime UtcNow => _timeProvider.GetUtcNow().UtcDateTime; + + public async Task GetNotificationsAsync(CancellationToken cancellationToken) + { + var now = UtcNow; + var cutoff = now.AddHours(48); + + var assignedWorkOrderIds = await _data.GetAssignedWorkOrderIdsAsync(cancellationToken); + var candidates = await _data.GetNoVendorWorkOrdersAsync(cutoff, assignedWorkOrderIds, cancellationToken); + + var noVendor = candidates.Select(wo => new NoVendorNotification + { + Id = $"no-vendor-{wo.Id}", + Type = "NoVendor", + Severity = "Medium", + Group = "No Vendor", + Title = "No vendor assigned — 48h to service", + WorkOrderId = wo.Id, + WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber, + TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48), + ScheduledAt = wo.ScheduledDate!.Value, + Action = new NotificationActionDto { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" } + }).Cast().ToList(); + + var dispatches = await _data.GetActiveScheduledDispatchesAsync(cancellationToken); + var conflicts = BuildConflictPairs(dispatches) + .Select(pair => (VendorNotificationItem)new VendorConflictNotification + { + Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}", + Type = "VendorConflict", + Severity = "Medium", + Group = "Vendor Conflict", + Title = "Vendor has overlapping appointments", + VendorId = pair.Left.VendorId, + VendorName = pair.Left.VendorName, + WorkOrders = new[] + { + new ConflictWorkOrderDto { WorkOrderId = pair.Left.WorkOrderId, WorkOrderNumber = pair.Left.WorkOrderNumber, Start = pair.Left.Start, End = pair.Left.End }, + new ConflictWorkOrderDto { WorkOrderId = pair.Right.WorkOrderId, WorkOrderNumber = pair.Right.WorkOrderNumber, Start = pair.Right.Start, End = pair.Right.End } + }, + TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start, + Actions = new[] + { + new NotificationActionDto { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" }, + new NotificationActionDto { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" } + } + }) + .ToList(); + + var items = noVendor.Concat(conflicts).OrderBy(item => item.TriggeredAt).ToList(); + return new VendorNotificationsResult { GeneratedAt = now, Items = items }; + } + + public async Task> GetAvailabilityAsync( + DateTime? start, DateTime? end, int? locationId, string? trade, CancellationToken cancellationToken) + { + var now = UtcNow; + var requestedStart = start ?? now; + var requestedEnd = end ?? requestedStart.AddHours(2); + if (requestedEnd <= requestedStart) + return VendorOperationOutcome.BadRequest("end must be after start"); + + var vendors = await _data.GetActiveVendorsAsync(cancellationToken); + var preferences = locationId.HasValue + ? await _data.GetSitePreferredVendorsAsync(locationId.Value, cancellationToken) + : new List(); + var dispatches = await _data.GetActiveScheduledDispatchesAsync(cancellationToken); + + var result = vendors.Select(vendor => + { + var matchingDispatches = dispatches + .Where(dispatch => dispatch.VendorId == vendor.Id + && WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End)) + .ToList(); + var matchingPreference = preferences + .Where(preference => preference.VendorId == vendor.Id + && (string.IsNullOrWhiteSpace(preference.Trade) + || string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase))) + .OrderBy(preference => preference.SortOrder) + .FirstOrDefault(); + var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue + && vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24); + var status = matchingDispatches.Count > 0 + ? "Unavailable" + : signalIsFresh && ValidAvailabilityStatuses.Contains(vendor.AvailabilityStatus ?? "") + ? vendor.AvailabilityStatus + : "Unknown"; + + return new VendorAvailabilityRow + { + Id = vendor.Id, + CompanyName = vendor.CompanyName, + ContactName = vendor.ContactName, + TradeSpecialties = vendor.TradeSpecialties, + AvailabilityStatus = status, + AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt, + IsPreferred = matchingPreference != null, + PreferenceRank = matchingPreference?.SortOrder, + Conflicts = matchingDispatches.Select(dispatch => new AvailabilityConflictDto + { + WorkOrderId = dispatch.WorkOrderId, + WorkOrderNumber = dispatch.WorkOrderNumber, + Start = dispatch.Start, + End = dispatch.End + }).ToList() + }; + }) + .OrderBy(vendor => vendor.IsPreferred ? 0 : 1) + .ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue) + .ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0 + : vendor.AvailabilityStatus == "Unknown" ? 1 : 2) + .ThenBy(vendor => vendor.CompanyName) + .ToList(); + + return VendorOperationOutcome.Ok( + new VendorAvailabilityResult { GeneratedAt = now, StaleAfterHours = 24, Data = result }); + } + + public async Task> UpdateAvailabilityAsync( + int vendorId, string? status, string actor, CancellationToken cancellationToken) + { + var normalized = status?.Trim(); + if (normalized is not ("Available" or "Unavailable" or "Unknown")) + return VendorOperationOutcome.BadRequest("status must be Available, Unavailable, or Unknown"); + + var vendor = await _data.GetVendorForUpdateAsync(vendorId, cancellationToken); + if (vendor == null) + return VendorOperationOutcome.NotFound(); + + var now = UtcNow; + var previous = vendor.AvailabilityStatus; + vendor.AvailabilityStatus = normalized; + vendor.AvailabilityUpdatedAt = now; + vendor.LastModificationTime = now; + await _data.StageVendorAuditAsync(new VendorAuditLog + { + VendorId = vendorId, + FieldName = nameof(Vendor.AvailabilityStatus), + OldValue = previous, + NewValue = normalized, + Actor = actor, + CreatedAt = now + }, cancellationToken); + await _data.SaveChangesAsync(cancellationToken); + return VendorOperationOutcome.Ok( + new VendorAvailabilityUpdateResult { Id = vendor.Id, AvailabilityStatus = vendor.AvailabilityStatus, AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt }); + } + + public async Task> GetSitePreferencesAsync(int locationId, CancellationToken cancellationToken) + { + var preferences = await _data.GetSitePreferredVendorsAsync(locationId, cancellationToken); + return preferences.Select(p => new SitePreferenceRow + { + Id = p.Id, + LocationId = p.LocationId, + VendorId = p.VendorId, + VendorName = p.Vendor!.CompanyName, + Trade = p.Trade, + SortOrder = p.SortOrder, + IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false), + LastModificationTime = p.LastModificationTime, + UpdatedBy = p.UpdatedBy + }).ToList(); + } + + public async Task>> ReplaceSitePreferencesAsync( + int locationId, IReadOnlyList body, string actor, CancellationToken cancellationToken) + { + if (!await _data.LocationExistsAsync(locationId, cancellationToken)) + return VendorOperationOutcome>.NotFound("Location not found"); + if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1)) + return VendorOperationOutcome>.BadRequest("Duplicate vendor/trade preference"); + + var vendorIds = body.Select(item => item.VendorId).Distinct().ToList(); + var vendors = await _data.GetVendorsByIdsAsync(vendorIds, cancellationToken); + foreach (var item in body) + { + if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false)) + return VendorOperationOutcome>.BadRequest($"Vendor {item.VendorId} is inactive or missing"); + if (!string.IsNullOrWhiteSpace(item.Trade) + && !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase)) + return VendorOperationOutcome>.BadRequest($"Vendor {item.VendorId} does not support trade '{item.Trade}'"); + } + + var now = UtcNow; + var existing = await _data.GetSitePreferredVendorsForReplaceAsync(locationId, cancellationToken); + await _data.RemoveSitePreferredVendorsAsync(existing, cancellationToken); + foreach (var (item, index) in body.Select((item, index) => (item, index))) + { + await _data.AddSitePreferredVendorAsync(new SitePreferredVendor + { + LocationId = locationId, + VendorId = item.VendorId, + Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(), + SortOrder = item.SortOrder ?? index, + CreatedDate = now, + LastModificationTime = now, + createdby = actor, + UpdatedBy = actor + }, cancellationToken); + } + foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct()) + { + await _data.StageVendorAuditAsync(new VendorAuditLog + { + VendorId = vendorId, + FieldName = $"SitePreference:{locationId}", + OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), + NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), + Actor = actor, + CreatedAt = now + }, cancellationToken); + } + await _data.SaveChangesAsync(cancellationToken); + return VendorOperationOutcome>.Ok(await GetSitePreferencesAsync(locationId, cancellationToken)); + } + + public async Task> ReviewDocumentAsync( + int documentId, DocumentReviewRequest body, string actor, CancellationToken cancellationToken) + { + if (body.ReviewStatus is not ("Approved" or "Rejected")) + return VendorOperationOutcome.BadRequest("reviewStatus must be Approved or Rejected"); + if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason)) + return VendorOperationOutcome.BadRequest("A rejection reason is required"); + + var document = await _data.GetCompletionDocumentForUpdateAsync(documentId, cancellationToken); + if (document == null) + return VendorOperationOutcome.NotFound(); + if (document.ScanStatus != "Passed") + return VendorOperationOutcome.Conflict("A document cannot be approved or reviewed until malware scanning passes"); + + var now = UtcNow; + var previous = document.ReviewStatus; + document.ReviewStatus = body.ReviewStatus; + document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null; + document.ReviewedAt = now; + document.LastModificationTime = now; + await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog + { + WorkOrderId = document.WorkOrderId, + UserId = actor, + FieldName = $"Completion Document {document.OriginalFileName}", + OldValue = previous, + NewValue = document.ReviewStatus, + Action = "completion_document_reviewed", + CreatedAt = now + }, cancellationToken); + await _data.SaveChangesAsync(cancellationToken); + return VendorOperationOutcome.Ok( + new DocumentReviewResult { Id = document.Id, ReviewStatus = document.ReviewStatus, RejectionReason = document.RejectionReason, ReviewedAt = document.ReviewedAt }); + } + + public async Task> UpdateCommercialStatusAsync( + int dispatchId, CommercialStatusRequest body, string actor, CancellationToken cancellationToken) + { + if (!InvoiceStatuses.Contains(body.InvoiceStatus)) + return VendorOperationOutcome.BadRequest("Invalid invoice status"); + if (!PaymentStatuses.Contains(body.PaymentStatus)) + return VendorOperationOutcome.BadRequest("Invalid payment status"); + + var dispatch = await _data.GetDispatchForUpdateAsync(dispatchId, cancellationToken); + if (dispatch == null) + return VendorOperationOutcome.NotFound(); + var now = UtcNow; + var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}"; + dispatch.InvoiceStatus = body.InvoiceStatus; + dispatch.PaymentStatus = body.PaymentStatus; + dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim(); + dispatch.CommercialStatusUpdatedAt = now; + dispatch.LastModificationTime = now; + await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = actor, + FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status", + OldValue = oldValue, + NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}", + Action = "commercial_status_updated", + CreatedAt = now + }, cancellationToken); + await _data.SaveChangesAsync(cancellationToken); + return VendorOperationOutcome.Ok( + new CommercialStatusResult + { + Id = dispatch.Id, + PONumber = dispatch.PONumber, + InvoiceNumber = dispatch.InvoiceNumber, + InvoiceStatus = dispatch.InvoiceStatus, + PaymentStatus = dispatch.PaymentStatus, + CommercialStatusUpdatedAt = dispatch.CommercialStatusUpdatedAt + }); + } + + public async Task> AssignVendorAsync( + int workOrderId, VendorAssignmentRequest body, string actor, CancellationToken cancellationToken) + { + var workOrder = await _data.GetWorkOrderForUpdateAsync(workOrderId, cancellationToken); + if (workOrder == null) + return VendorOperationOutcome.NotFound("Work order not found"); + var vendor = await _data.GetVendorForUpdateAsync(body.VendorId, cancellationToken); + if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false)) + return VendorOperationOutcome.BadRequest("Only an active vendor can be assigned"); + + var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate; + var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2); + var activeDispatches = start.HasValue && end.HasValue + ? (await _data.GetActiveScheduledDispatchesAsync(cancellationToken)) + .Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId).ToList() + : new List(); + var conflicts = start.HasValue && end.HasValue + ? activeDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList() + : new List(); + + var dispatch = await _data.GetExistingActiveDispatchForWorkOrderAsync(workOrderId, cancellationToken); + var now = UtcNow; + var previousVendorId = dispatch?.VendorId; + if (dispatch == null) + { + dispatch = new Dispatch + { + WorkOrderId = workOrderId, + VendorId = vendor.Id, + DispatchNumber = await _data.GenerateNextDispatchNumberAsync(cancellationToken), + PONumber = await _data.GenerateNextPONumberAsync(cancellationToken), + Status = "Sent", + ScheduledDate = start, + EstimatedArrivalAt = body.EstimatedArrivalAt, + EtaManualOverride = body.EtaManualOverride, + DispatchedAt = now, + CreatedDate = now, + createdby = actor + }; + await _data.AddDispatchAsync(dispatch, cancellationToken); + } + else + { + dispatch.VendorId = vendor.Id; + dispatch.ScheduledDate = start; + dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt; + dispatch.EtaManualOverride = body.EtaManualOverride; + dispatch.LastModificationTime = now; + } + + workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart; + workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd; + workOrder.ScheduledDate = start ?? workOrder.ScheduledDate; + workOrder.LastModificationTime = now; + await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + UserId = actor, + FieldName = "Vendor Assignment", + OldValue = previousVendorId?.ToString(), + NewValue = vendor.Id.ToString(), + Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned", + CreatedAt = now + }, cancellationToken); + await _data.SaveChangesAsync(cancellationToken); + + return VendorOperationOutcome.Ok(new VendorAssignmentResult + { + Id = dispatch.Id, + VendorId = dispatch.VendorId, + VendorName = vendor.CompanyName, + EstimatedArrivalAt = dispatch.EstimatedArrivalAt, + EtaManualOverride = dispatch.EtaManualOverride, + AvailabilityStatus = conflicts.Count > 0 ? "Unavailable" + : vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown", + Conflicts = conflicts.Select(conflict => new AvailabilityConflictDto + { + WorkOrderId = conflict.WorkOrderId, + WorkOrderNumber = conflict.WorkOrderNumber, + Start = conflict.Start, + End = conflict.End + }).ToList() + }); + } + + public async Task> GetInsightRowsAsync( + DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken) + { + var dispatches = await _data.GetInsightDispatchDataAsync(from, to, locationId, trade, vendorId, cancellationToken); + return dispatches.GroupBy(dispatch => dispatch.VendorId) + .Select(group => + { + var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList(); + var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused"); + var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList(); + var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.DueDate != null).ToList(); + var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList(); + return new VendorInsightRow + { + VendorId = group.Key, + Vendor = group.First().VendorName ?? $"Vendor {group.Key}", + CompletedJobs = completed.Count, + CancellationRefusalRate = Rate(canceled, group.Count()), + OnTimeArrivalRate = Rate( + acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate), + acknowledgedWithSchedule.Count), + OnTimeCompletionRate = Rate( + completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.DueDate), + completedWithDue.Count), + AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch => + (dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours) + }; + }) + .OrderBy(row => row.Vendor) + .ToList(); + } + + // Group by vendor so cross-vendor intervals are never compared, then sweep the + // start-sorted windows within a vendor. Every overlapping pair is still emitted; + // output is O(n log n + p) where p is the number of emitted pairs, so the worst + // case stays quadratic only when a single vendor has many mutually-overlapping + // windows, which is inherent to enumerating all such pairs. + private static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> BuildConflictPairs( + IReadOnlyList dispatches) + { + foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId)) + { + var sorted = group.OrderBy(dispatch => dispatch.Start).ToList(); + for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++) + { + var left = sorted[leftIndex]; + for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++) + { + var right = sorted[rightIndex]; + // Sorted by start: once a window starts at or after left's end, none that follow can overlap it. + if (right.Start >= left.End) + break; + if (left.WorkOrderId != right.WorkOrderId) + yield return (left, right); + } + } + } + } + + private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd) + => leftStart < rightEnd && rightStart < leftEnd; + + private static decimal Rate(int numerator, int denominator) + => denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4); + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs new file mode 100644 index 0000000..70d1ddb --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -0,0 +1,703 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class VendorPortalService : IVendorPortalService + { + private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "application/pdf", "image/jpeg", "image/jpg", "image/png" + }; + + private readonly IVendorPortalTokenService _tokens; + private readonly IDispatchDataService _dispatchData; + private readonly IUpliftDataService _upliftData; + private readonly ICommentDataService _commentData; + private readonly IUserDataService _userData; + private readonly IEmailSender _emailSender; + private readonly IVendorDocumentDataService _documentData; + private readonly IVendorDocumentStoragePort _documentStorage; + private readonly FrontendOptions _frontendOptions; + private readonly ApprovalsOptions _approvalsOptions; + private readonly VendorDocumentsOptions _documentOptions; + + public VendorPortalService( + IVendorPortalTokenService tokens, + IDispatchDataService dispatchData, + IUpliftDataService upliftData, + ICommentDataService commentData, + IUserDataService userData, + IEmailSender emailSender, + IVendorDocumentDataService documentData, + IVendorDocumentStoragePort documentStorage, + IOptions frontendOptions, + IOptions approvalsOptions, + IOptions documentOptions) + { + _tokens = tokens; + _dispatchData = dispatchData; + _upliftData = upliftData; + _commentData = commentData; + _userData = userData; + _emailSender = emailSender; + _documentData = documentData; + _documentStorage = documentStorage; + _frontendOptions = frontendOptions.Value; + _approvalsOptions = approvalsOptions.Value; + _documentOptions = documentOptions.Value; + } + + public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) + { + return await _tokens.ResolveSessionAsync(token, cancellationToken); + } + + public async Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken) + { + var summaries = await _dispatchData.GetVendorDispatchSummariesAsync(session.Id, status, cancellationToken); + + return summaries.Select(d => new VendorDispatchSummaryDTO + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + Status = d.Status, + NTEAmount = d.NTEAmount, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + DispatchedAt = d.DispatchedAt, + AcknowledgedAt = d.AcknowledgedAt, + WorkOrderTitle = d.WorkOrderTitle, + InternalWONumber = d.InternalWONumber, + LocationName = d.LocationName, + LocationCity = d.LocationCity, + LocationState = d.LocationState + }); + } + + public async Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchDetailAsync(id, session.Id, cancellationToken); + if (dispatch == null) return null; + + var checklist = await _dispatchData.GetPortalChecklistAsync(id, cancellationToken); + var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken); + var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken); + var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken); + + var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + RequestedAt = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() + }).ToList(); + + var comments = rawComments.Select(c => + { + var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); + var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); + + string resolvedType; + string resolvedCommenter; + if (hasShocUser) + { + resolvedType = "dispatcher"; + resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName + : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! + : "Dispatcher"; + } + else if (c.CommentType == "customer") + { + resolvedType = "customer"; + resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; + } + else + { + resolvedType = "vendor"; + resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : session.CompanyName ?? "Vendor"; + } + + return new PortalCommentDTO + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = resolvedCommenter, + CommentType = resolvedType, + CreatedDate = c.CreatedDate + }; + }).ToList(); + + return new VendorDispatchDetailDTO + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + Status = dispatch.Status, + NTEAmount = dispatch.NTEAmount, + Description = dispatch.Description, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + AcknowledgedAt = dispatch.AcknowledgedAt, + WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO + { + Id = dispatch.WorkOrder.Id, + InternalWONumber = dispatch.WorkOrder.InternalWONumber, + WorkerOrderTitle = dispatch.WorkOrder.WorkerOrderTitle, + Description = dispatch.WorkOrder.Description, + Priority = dispatch.WorkOrder.Priority, + DueDate = dispatch.WorkOrder.DueDate, + Trade = dispatch.WorkOrder.Trade, + SubTrade = dispatch.WorkOrder.SubTrade, + Problem = dispatch.WorkOrder.Problem + }, + Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO + { + Name = dispatch.WorkOrder.Locations.Name, + Address1 = dispatch.WorkOrder.Locations.Address1, + City = dispatch.WorkOrder.Locations.City, + State = dispatch.WorkOrder.Locations.State, + Zip = dispatch.WorkOrder.Locations.Zip + }, + Checklist = checklist.Select(c => new PortalChecklistItemDTO + { + Id = c.Id, + ItemText = c.ItemText, + IsCompleted = c.IsCompleted, + CompletedBy = c.CompletedBy, + CompletedAt = c.CompletedAt + }), + Signoffs = signoffs.Select(s => new PortalSignoffDTO + { + Id = s.Id, + SignoffType = s.SignoffType, + Name = s.Name, + SignatureMethod = s.SignatureMethod, + SignedAt = s.SignedAt + }), + Comments = comments, + UpliftRequests = upliftRequests + }; + } + + public async Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status != "Sent") + { + throw new InvalidOperationException($"Cannot accept a dispatch with status '{dispatch.Status}'"); + } + + var now = DateTime.UtcNow; + dispatch.Status = "Acknowledged"; + dispatch.AcknowledgedAt = now; + dispatch.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = "Sent", + NewValue = "Acknowledged", + Action = "vendor_accept", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt }; + } + + public async Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + var from = dispatch.Status; + if (!IsAllowedVendorTransition(from, to)) + { + throw new InvalidOperationException($"Transition from '{from}' to '{to}' is not allowed"); + } + + var now = DateTime.UtcNow; + dispatch.Status = to; + dispatch.LastModificationTime = now; + if (to == "Completed") dispatch.CompletedDate = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = from, + NewValue = to, + Action = "vendor_status_change", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new ChangeStatusResultDTO { Id = dispatch.Id, Status = dispatch.Status, CompletedDate = dispatch.CompletedDate }; + } + + public async Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch"); + } + + var now = DateTime.UtcNow; + var reasonText = string.IsNullOrWhiteSpace(reason) ? "(no reason provided)" : reason!.Trim(); + + await _commentData.StageAsync(new Comments + { + DispatchId = id, + WorkerOrderId = dispatch.WorkOrderId, + Commenter = session.CompanyName, + CommentType = "vendor", + RecordType = "cancel_request", + Commenttext = $"Vendor requested cancellation: {reasonText}", + CreatedDate = now + }, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber}", + OldValue = dispatch.Status, + NewValue = "Cancel Requested", + Action = "vendor_request_cancel", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException("Dispatch is locked"); + } + + var item = await _dispatchData.GetChecklistItemForDispatchAsync(itemId, id, cancellationToken); + if (item == null) throw new KeyNotFoundException("Checklist item not found"); + + var now = DateTime.UtcNow; + item.IsCompleted = isCompleted; + item.CompletedBy = isCompleted ? session.CompanyName : null; + item.CompletedAt = isCompleted ? now : null; + item.LastModificationTime = now; + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new ChecklistItemResultDTO + { + Id = item.Id, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }; + } + + public async Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") + { + throw new InvalidOperationException("Signoffs only allowed on In Progress or Completed dispatches"); + } + + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) + { + throw new InvalidOperationException("Name and signature are required"); + } + + var normalizedSignoffType = (signoffType ?? "vendor").Trim().ToLowerInvariant(); + if (normalizedSignoffType != "vendor" && normalizedSignoffType != "customer") + { + throw new InvalidOperationException("signoffType must be 'vendor' or 'customer'"); + } + + var existing = await _dispatchData.HasSignoffTypeAsync(id, normalizedSignoffType); + if (existing) + { + throw new InvalidOperationException($"A {normalizedSignoffType} signoff already exists for this dispatch"); + } + + var now = DateTime.UtcNow; + var signoff = new DispatchSignoff + { + DispatchId = id, + SignoffType = normalizedSignoffType, + Name = name, + Signature = signature, + SignatureMethod = signatureMethod ?? "drawn", + SignedAt = now, + CreatedDate = now + }; + await _dispatchData.StageSignoffAsync(signoff, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", + OldValue = null, + NewValue = $"{(normalizedSignoffType == "customer" ? "Customer" : "Vendor")}: {name}", + Action = normalizedSignoffType == "customer" ? "customer_signoff" : "vendor_signoff", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new SignoffResultDTO { Id = signoff.Id, SignedAt = signoff.SignedAt }; + } + + public async Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (string.IsNullOrWhiteSpace(commentText)) + { + throw new InvalidOperationException("Comment cannot be empty"); + } + + var now = DateTime.UtcNow; + var comment = new Comments + { + DispatchId = id, + WorkerOrderId = dispatch.WorkOrderId, + Commenter = session.CompanyName, + CommentType = "vendor", + RecordType = "comment", + Commenttext = commentText, + CreatedDate = now + }; + await _commentData.StageAsync(comment, cancellationToken); + await _dispatchData.SaveChangesAsync(cancellationToken); + + return new CommentResultDTO + { + Id = comment.Id, + Commenttext = comment.Commenttext, + Commenter = comment.Commenter, + CreatedDate = comment.CreatedDate + }; + } + + public async Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); + } + + if (requestedNTE <= 0) + { + throw new InvalidOperationException("Requested NTE must be greater than zero"); + } + + var current = dispatch.NTEAmount ?? 0m; + if (requestedNTE <= current) + { + throw new InvalidOperationException("Requested NTE must be greater than the current NTE"); + } + + var pendingExists = await _upliftData.HasPendingAsync(id, cancellationToken); + if (pendingExists) + { + throw new InvalidOperationException("A pending uplift request already exists for this dispatch"); + } + + var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; + var delta = requestedNTE - current; + var requiredTier = delta > tier1Max ? 2 : 1; + + var now = DateTime.UtcNow; + var req = new DispatchUpliftRequest + { + DispatchId = id, + CurrentNTE = current, + RequestedNTE = requestedNTE, + VendorReason = string.IsNullOrWhiteSpace(reason) ? null : reason!.Trim(), + Status = "Pending", + RequiredTier = requiredTier, + RequestedByVendorName = session.CompanyName, + CreatedDate = now + }; + await _upliftData.StageAsync(req, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = $"${current:F2}", + NewValue = $"${requestedNTE:F2}", + Action = "uplift_requested", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + + await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken); + + return new UpliftRequestResultDTO + { + Id = req.Id, + Status = req.Status, + RequiredTier = req.RequiredTier, + CurrentNTE = req.CurrentNTE, + RequestedNTE = req.RequestedNTE + }; + } + + public async Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + + if (req.Status != "Pending") + { + throw new InvalidOperationException($"Cannot cancel a '{req.Status}' uplift request"); + } + + var now = DateTime.UtcNow; + req.Status = "Cancelled"; + req.DecidedAt = now; + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = "Pending", + NewValue = "Cancelled", + Action = "uplift_cancelled", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status }; + } + + public async Task UploadCompletionDocumentAsync( + VendorPortalSession session, int dispatchId, IFormFile file, CancellationToken cancellationToken) + { + if (file is null || file.Length == 0) + { + throw new InvalidOperationException("A completion document file is required."); + } + + if (file.Length > _documentOptions.MaxSizeBytes) + { + throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); + } + + var contentType = (file.ContentType ?? string.Empty).Trim(); + if (!AllowedContentTypes.Contains(contentType)) + { + throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted."); + } + + using var buffer = new MemoryStream(); + await file.CopyToAsync(buffer, cancellationToken); + var bytes = buffer.ToArray(); + + if (!MatchesSignature(contentType, bytes)) + { + throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); + } + + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); + if (dispatch == null) + { + throw new KeyNotFoundException("Dispatch not found"); + } + + var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken); + var version = (latest?.Version ?? 0) + 1; + var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; + + var now = DateTime.UtcNow; + var document = new VendorCompletionDocument + { + VendorId = session.Id, + DispatchId = dispatchId, + WorkOrderId = dispatch.WorkOrderId ?? 0, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = "Pending", + ReviewStatus = "Processing", + Version = version, + ReplacesDocumentId = latest?.Id, + CreatedDate = now + }; + + await _documentData.AddAsync(document, cancellationToken); + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + DispatchId = dispatchId, + FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document", + OldValue = latest == null ? null : $"v{latest.Version}", + NewValue = $"v{version}", + Action = "vendor_completion_document_uploaded", + ActorType = "vendor", + CreatedAt = now + }, cancellationToken); + await _documentData.SaveChangesAsync(cancellationToken); + + using var stored = new MemoryStream(bytes); + await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken); + + return new UploadCompletionDocumentResultDTO + { + Id = document.Id, + Version = document.Version, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus + }; + } + + public async Task DownloadCompletionDocumentAsync( + VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken) + { + var document = await _documentData.GetForVendorDispatchAsync(documentId, dispatchId, session.Id, cancellationToken); + if (document == null) + { + return DownloadCompletionDocumentResultDTO.NotFound(); + } + + if (!string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)) + { + return DownloadCompletionDocumentResultDTO.Locked(); + } + + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); + if (dispatch != null && (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")) + { + return DownloadCompletionDocumentResultDTO.Locked(); + } + + var content = _documentStorage.OpenRead(session.Id, dispatchId, document.StoredFileName); + return DownloadCompletionDocumentResultDTO.Ok(content, document.ContentType, document.OriginalFileName); + } + + private static bool MatchesSignature(string contentType, byte[] bytes) + { + if (bytes.Length == 0) + { + return false; + } + + if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 4 + && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF + } + + if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 + && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; + } + + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) + || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + } + + return false; + } + + private static string GetSafeExtension(string fileName) + { + var extension = Path.GetExtension(fileName); + return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension; + } + + private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken) + { + try + { + var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken); + if (string.IsNullOrWhiteSpace(dispatcherUserId)) return; + + var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); + if (string.IsNullOrWhiteSpace(dispatcherEmail)) return; + + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; + var workOrderLink = dispatch.WorkOrderId.HasValue + ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" + : frontendBase; + + var vendorName = System.Net.WebUtility.HtmlEncode(session.CompanyName ?? "Vendor"); + var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); + var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); + var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; + + var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {session.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; + var body = $@" +

Vendor Uplift Request

+

Dispatch: {dispatchNum}

+

Vendor: {vendorName}

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

Vendor Reason

+

{reason}

+ "; + + await _emailSender.SendEmailAsync(dispatcherEmail, subject, body); + } + catch + { + } + } + + private static bool IsAllowedVendorTransition(string? from, string? to) + { + if (from == "Acknowledged" && to == "In Progress") return true; + if (from == "In Progress" && to == "Completed") return true; + return false; + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalTokenService.cs b/SeaHaven.Services/Implementation/VendorPortalTokenService.cs new file mode 100644 index 0000000..377b333 --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorPortalTokenService.cs @@ -0,0 +1,93 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Cryptography; + +namespace SeaHaven.Services.Implementation +{ + public class VendorPortalTokenService : IVendorPortalTokenService + { + private readonly IVendorDataService _vendorDataService; + private readonly int _lifetimeDays; + + public VendorPortalTokenService(IVendorDataService vendorDataService, IOptions options) + { + _vendorDataService = vendorDataService; + _lifetimeDays = options.Value.TokenLifetimeDays ?? 365; + } + + public async Task GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var existing = await _vendorDataService.GetActiveTokenForVendorAsync(vendorId, cancellationToken); + if (existing != null) return MapToken(existing); + + var token = new VendorAccessToken + { + VendorId = vendorId, + Token = GenerateToken(), + IssuedAt = now, + ExpiresAt = now.AddDays(_lifetimeDays), + CreatedDate = now + }; + return MapToken(await _vendorDataService.AddTokenAsync(token, cancellationToken)); + } + + public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(token)) return null; + var now = DateTime.UtcNow; + var record = await _vendorDataService.GetTokenWithVendorAsync(token, cancellationToken); + + if (record?.Vendor == null) return null; + + record.LastUsedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + return new VendorPortalSession + { + Id = record.Vendor.Id, + CompanyName = record.Vendor.CompanyName, + ContactName = record.Vendor.ContactName, + Email = record.Vendor.Email, + Phone = record.Vendor.Phone + }; + } + + public async Task RotateAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var active = await _vendorDataService.GetRevocableTokensAsync(vendorId, cancellationToken); + foreach (var t in active) t.RevokedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + return await GetOrCreateActiveTokenAsync(vendorId, cancellationToken); + } + + public async Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var active = await _vendorDataService.GetRevocableTokensAsync(vendorId, cancellationToken); + foreach (var t in active) t.RevokedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + } + + private static string GenerateToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } + + private static VendorPortalTokenStateDTO MapToken(VendorAccessToken token) => new() + { + Token = token.Token, + IssuedAt = token.IssuedAt, + ExpiresAt = token.ExpiresAt, + LastUsedAt = token.LastUsedAt + }; + } +} diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 009a9e4..117b0da 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -2,7 +2,9 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using FluentValidation.Results; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; @@ -16,17 +18,26 @@ namespace SeaHaven.Services.Implementation { "completed", "cancelled", "canceled" }; private readonly IVendorDataService _vendorDataService; + private readonly IVendorPortalTokenService _vendorTokenService; + private readonly IZipCodeDistance _zipDistance; + private readonly FrontendOptions _frontendOptions; private readonly ICreateVendorValidation _createValidator; private readonly IUpdateVendorValidation _updateValidator; private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; public VendorService( IVendorDataService vendorDataService, + IVendorPortalTokenService vendorTokenService, + IZipCodeDistance zipDistance, + IOptions frontendOptions, ICreateVendorValidation createValidator, IUpdateVendorValidation updateValidator, IWorkOrderVendorUpdateValidation workOrderUpdateValidator) { _vendorDataService = vendorDataService; + _vendorTokenService = vendorTokenService; + _zipDistance = zipDistance; + _frontendOptions = frontendOptions.Value; _createValidator = createValidator; _updateValidator = updateValidator; _workOrderUpdateValidator = workOrderUpdateValidator; @@ -519,5 +530,136 @@ namespace SeaHaven.Services.Implementation .Distinct() .Count(); } + + public async Task> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken) + { + var vendors = await _vendorDataService.GetActiveVendorsAsync(cancellationToken); + + var result = vendors.Select(v => + { + var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip); + var addr = new[] { v.Address, v.City, v.State, v.Zip } + .Where(s => !string.IsNullOrWhiteSpace(s)); + return new VendorDropdownItemDTO + { + Id = v.Id, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + PreferredContact = v.PreferredContact, + TradeSpecialties = v.TradeSpecialties, + Address = string.Join(", ", addr), + DistanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null + }; + }).ToList(); + + if (!string.IsNullOrWhiteSpace(trade)) + { + var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList(); + var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList(); + result = tradeMatched.Concat(rest).ToList(); + } + + if (!string.IsNullOrWhiteSpace(siteZip)) + { + result = result.OrderBy(v => v.DistanceMiles ?? 99999).ToList(); + } + + return result; + } + + public async Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken) + { + var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken); + + var companies = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) + .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(v => v.CompanyName) + .Select(v => new VendorFacetCompanyDTO + { + Name = v.CompanyName, + CompanyId = v.CompanyId, + CompanyPhone = v.CompanyPhone, + Email = v.Email, + PreferredContact = v.PreferredContact, + Address = v.Address, + City = v.City, + State = v.State, + Zip = v.Zip, + GoogleMapsUrl = v.GoogleMapsUrl + }); + + var trades = vendors + .SelectMany(v => (v.TradeSpecialties ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value); + + var locations = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) + .Select(v => new VendorFacetLocationDTO + { + City = v.City, + State = v.State, + Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) + }) + .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) + .OrderBy(location => location.Label); + + return new VendorFacetsDTO + { + Companies = companies, + Trades = trades, + Locations = locations, + JobBuckets = new[] + { + new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" }, + new VendorJobBucketDTO { Id = "50-99", Label = "50–99" }, + new VendorJobBucketDTO { Id = "100-149", Label = "100–149" }, + new VendorJobBucketDTO { Id = "150-plus", Label = "150+" } + } + }; + } + + public async Task GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return null; + + var token = await _vendorTokenService.GetOrCreateActiveTokenAsync(vendorId, cancellationToken); + return BuildPortalTokenResponse(token); + } + + public async Task RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return null; + + var token = await _vendorTokenService.RotateAsync(vendorId, cancellationToken); + return BuildPortalTokenResponse(token); + } + + public async Task RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return false; + + await _vendorTokenService.RevokeAllAsync(vendorId, cancellationToken); + return true; + } + + private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token) + { + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; + return new VendorPortalTokenDTO + { + Token = token.Token, + IssuedAt = token.IssuedAt, + ExpiresAt = token.ExpiresAt, + LastUsedAt = token.LastUsedAt, + PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard" + }; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index cc1f30a..aa4a171 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -1,28 +1,29 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class WorkOrderAuditService : IWorkOrderAuditService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderAuditDataService _auditData; private readonly IWorkOrderFieldLockService _fieldLocks; - public WorkOrderAuditService(ApplicationDbContext context, IWorkOrderFieldLockService fieldLocks) + public WorkOrderAuditService(IWorkOrderAuditDataService auditData, IWorkOrderFieldLockService fieldLocks) { - _context = context; + _auditData = auditData; _fieldLocks = fieldLocks; } public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) => LogAsync(workOrderId, AuditActionType.StatusChanged, AuditEventType.Manual, - AuditActorType.Dispatcher, "LifecycleStatus", oldStatus, newStatus, actorId); + AuditActorType.Dispatcher, WorkOrderFieldNames.LifecycleStatus, oldStatus, newStatus, actorId); public Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId) => LogAsync(workOrderId, AuditActionType.AssignmentChanged, AuditEventType.Manual, - AuditActorType.Dispatcher, "AssignTo", oldValue, newValue, actorId); + AuditActorType.Dispatcher, WorkOrderFieldNames.AssignTo, oldValue, newValue, actorId); public Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue) => LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync, @@ -43,10 +44,10 @@ namespace SeaHaven.Services.Implementation => StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId); public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId) - => StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId); + => StageAuditAsync(workOrderId, AuditActionType.StatusChanged, WorkOrderFieldNames.LifecycleStatus, oldStatus, newStatus, actorId); public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId) - => StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId); + => StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, WorkOrderFieldNames.AssignTo, oldValue, newValue, actorId); public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber) => StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId); @@ -63,7 +64,7 @@ namespace SeaHaven.Services.Implementation int? dispatchId = null, string? correlationId = null) { - AddAuditEntry(workOrderId, action, eventType, actorType, fieldName, oldValue, newValue, actorId, dispatchId, correlationId); + Enqueue(workOrderId, action, eventType, actorType, fieldName, oldValue, newValue, actorId, dispatchId, correlationId); if (eventType == AuditEventType.Manual && (action == AuditActionType.FieldChanged || @@ -73,7 +74,7 @@ namespace SeaHaven.Services.Implementation await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); } - await _context.SaveChangesAsync(); + await _auditData.PersistAsync(CancellationToken.None); } private async Task StageAuditAsync( @@ -85,7 +86,7 @@ namespace SeaHaven.Services.Implementation string? actorId, int? dispatchId = null) { - AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher, + Enqueue(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher, fieldName, oldValue, newValue, actorId, dispatchId); if (action == AuditActionType.FieldChanged || @@ -96,7 +97,7 @@ namespace SeaHaven.Services.Implementation } } - private void AddAuditEntry( + private void Enqueue( int workOrderId, AuditActionType action, AuditEventType eventType, @@ -108,7 +109,7 @@ namespace SeaHaven.Services.Implementation int? dispatchId = null, string? correlationId = null) { - _context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + _auditData.EnqueueAuditLog(new WorkOrderAuditLog { WorkOrderId = workOrderId, UserId = actorId, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index 78c86bd..7902978 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -1,6 +1,5 @@ -using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; @@ -10,24 +9,23 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderBoardCancelService : IWorkOrderBoardCancelService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; public WorkOrderBoardCancelService( - ApplicationDbContext context, + IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, IWorkOrderAuditService auditService) { - _context = context; + _mutationData = mutationData; _boardService = boardService; _auditService = auditService; } public async Task CancelAsync(int workOrderId, string? actorId) { - var workOrder = await _context.workOrders - .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); @@ -48,7 +46,7 @@ namespace SeaHaven.Services.Implementation workOrder.LegacyStatus = workOrder.Status; await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); - await _context.SaveChangesAsync(); + await _mutationData.SaveAsync(CancellationToken.None); var row = await _boardService.GetBoardRowAsync(workOrderId); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index b4429c0..b7ad1ee 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; -using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -14,21 +13,21 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderBoardCreateService : IWorkOrderBoardCreateService { - private readonly ApplicationDbContext _context; private readonly IWorkOrderBoardDataService _boardDataService; + private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderBoardCreateValidation _validator; public WorkOrderBoardCreateService( - ApplicationDbContext context, IWorkOrderBoardDataService boardDataService, + IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, IWorkOrderAuditService auditService, IWorkOrderBoardCreateValidation validator) { - _context = context; _boardDataService = boardDataService; + _mutationData = mutationData; _boardService = boardService; _auditService = auditService; _validator = validator; @@ -56,8 +55,7 @@ namespace SeaHaven.Services.Implementation if (request.VendorId.HasValue && request.VendorId.Value > 0) { - var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == request.VendorId.Value && v.IsDeleted != true); - if (!vendorExists) + if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, CancellationToken.None)) throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); } @@ -153,7 +151,7 @@ namespace SeaHaven.Services.Implementation TechPhone = techPhone, VendorNotes = vendorNotes }; - _context.Dispatches.Add(dispatch); + _mutationData.TrackNewDispatch(dispatch); workOrder.PrimaryDispatch = dispatch; changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorId", "", request.VendorId.Value.ToString())); @@ -175,14 +173,10 @@ namespace SeaHaven.Services.Implementation // Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and // audit/lock/contact rows. A transaction keeps the create atomic across both saves. - await using var transaction = _context.Database.IsRelational() - ? await _context.Database.BeginTransactionAsync() - : null; - - try + await _mutationData.ExecuteTransactionalAsync(async ct => { - _context.workOrders.Add(workOrder); - await _context.SaveChangesAsync(); + _mutationData.TrackNewWorkOrder(workOrder); + var firstOutcome = await _mutationData.SaveAsync(ct); if (dispatch != null && workOrder.PrimaryDispatchId == null) { @@ -196,25 +190,12 @@ namespace SeaHaven.Services.Implementation if (request.PocContactId.HasValue) { - _context.WorkOrderContacts.Add(new WorkOrderContacts - { - WorkorderId = workOrder.Id, - ContactId = request.PocContactId, - Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim() - }); + _mutationData.TrackWorkOrderContact(workOrder.Id, request.PocContactId.Value, + string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()); } - await _context.SaveChangesAsync(); - - if (transaction is not null) - await transaction.CommitAsync(); - } - catch - { - if (transaction is not null) - await transaction.RollbackAsync(); - throw; - } + await _mutationData.SaveAsync(ct); + }, CancellationToken.None); var row = await _boardService.GetBoardRowAsync(workOrder.Id); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded."); @@ -290,7 +271,7 @@ namespace SeaHaven.Services.Implementation private async Task GenerateNextSequentialWoNumberAsync() { - var maxId = await _context.workOrders.MaxAsync(w => (int?)w.Id) ?? 0; + var maxId = await _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None); var seed = Math.Max(maxId + 1, 1); if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _)) normalized = seed.ToString().PadLeft(11, '0'); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 7ea2fad..bdab70a 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; @@ -13,17 +12,17 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderBoardUpdateService : IWorkOrderBoardUpdateService { - private readonly ApplicationDbContext _context; private readonly IWorkOrderBoardDataService _boardDataService; + private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderAuditService _auditService; public WorkOrderBoardUpdateService( - ApplicationDbContext context, IWorkOrderBoardDataService boardDataService, + IWorkOrderBoardMutationDataService mutationData, IWorkOrderAuditService auditService) { - _context = context; _boardDataService = boardDataService; + _mutationData = mutationData; _auditService = auditService; } @@ -32,137 +31,104 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardPatchRequestDto request, string? actorId) { - // Intermediate SaveChanges (e.g. new dispatch identity) must stay atomic with the final patch save. - await using var transaction = _context.Database.IsRelational() - ? await _context.Database.BeginTransactionAsync() - : null; - - try + await _mutationData.ExecuteTransactionalAsync(async ct => { - if (string.IsNullOrWhiteSpace(request.Field)) - throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); + if (string.IsNullOrWhiteSpace(request.Field)) + throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); - var field = request.Field.Trim(); - var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field); - if (canonicalField == null) - throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable."); + var field = request.Field.Trim(); + var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field); + if (canonicalField == null) + throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable."); - var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion"); - if (workOrderVersion == null) - throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); + var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion"); + if (workOrderVersion == null) + throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); - var dispatchVersion = ParseRowVersion(request.DispatchVersion, "dispatchVersion"); + var dispatchVersion = ParseRowVersion(request.DispatchVersion, "dispatchVersion"); - var workOrder = await _context.workOrders - .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, ct); - if (workOrder == null) - throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion)) - { - var staleState = await LoadBoardRowAsync(workOrderId); - throw new WorkOrderBoardConcurrencyException(staleState); - } - - if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) - throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status."); - - var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus); - if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue)) - throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first."); - - _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion; - - Dispatch? dispatch = null; - if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField)) - { - var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase) - ? ParseVendorIdHint(request.Value) - : null; - - var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue; - dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint); - - if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion)) + if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion)) { var staleState = await LoadBoardRowAsync(workOrderId); throw new WorkOrderBoardConcurrencyException(staleState); } - if (dispatchVersion != null) - _context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = dispatchVersion; - else if (hadPrimaryDispatch && dispatch.RowVersion != null) - throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields."); - } + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status."); - var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField); - var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField); - changes = changes.Where(c => c.HasChanged).ToList(); + var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus); + if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue)) + throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first."); - if (changes.Count == 0 && !_context.ChangeTracker.HasChanges()) - { - var unchanged = await LoadBoardRowAsync(workOrderId); - if (transaction is not null) - await transaction.CommitAsync(); - return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - } + _mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); - if (changes.Count == 0) - { - await _context.SaveChangesAsync(); - if (transaction is not null) - await transaction.CommitAsync(); - var persisted = await LoadBoardRowAsync(workOrderId); - return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - } - - foreach (var change in changes) - { - var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null); - switch (change.Action) + Dispatch? dispatch = null; + if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField)) { - case AuditActionType.StatusChanged: - await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); - break; - case AuditActionType.AssignmentChanged: - await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); - break; - default: - await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId); - break; + var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase) + ? ParseVendorIdHint(request.Value) + : null; + + var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue; + dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint, ct); + + if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion)) + { + var staleState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(staleState); + } + + if (dispatchVersion != null) + _mutationData.SetExpectedDispatchVersion(dispatch, dispatchVersion); + else if (hadPrimaryDispatch && dispatch.RowVersion != null) + throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields."); } - } - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateConcurrencyException) - { - if (transaction is not null) - await transaction.RollbackAsync(); - _context.ChangeTracker.Clear(); - var currentState = await LoadBoardRowAsync(workOrderId); - throw new WorkOrderBoardConcurrencyException(currentState); - } + var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField); + var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField); + changes = changes.Where(c => c.HasChanged).ToList(); - if (transaction is not null) - await transaction.CommitAsync(); + if (changes.Count == 0 && !_mutationData.HasPendingChanges()) + return; + + if (changes.Count == 0) + { + await _mutationData.SaveAsync(ct); + return; + } + + foreach (var change in changes) + { + var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null); + switch (change.Action) + { + case AuditActionType.StatusChanged: + await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + break; + case AuditActionType.AssignmentChanged: + await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + break; + default: + await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId); + break; + } + } + + var outcome = await _mutationData.SaveAsync(ct); + if (outcome == BoardSaveOutcome.ConcurrencyConflict) + { + var currentState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(currentState); + } + }, CancellationToken.None); var row = await LoadBoardRowAsync(workOrderId); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - } - catch (WorkOrderBoardConcurrencyException) - { - throw; - } - catch - { - if (transaction is not null) - await transaction.RollbackAsync(); - throw; - } } private async Task> ApplyFieldMutationAsync( @@ -195,12 +161,11 @@ namespace SeaHaven.Services.Implementation }; } - private async Task ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate) + private async Task ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate, CancellationToken cancellationToken) { if (primaryDispatchId.HasValue) { - var dispatch = await _context.Dispatches - .FirstOrDefaultAsync(d => d.Id == primaryDispatchId.Value && d.WorkOrderId == workOrder.Id); + var dispatch = await _mutationData.GetTrackedDispatchAsync(primaryDispatchId.Value, workOrder.Id, cancellationToken); if (dispatch == null) throw new WorkOrderBoardValidationException("InvalidDispatch", "Primary dispatch does not belong to this work order."); @@ -211,8 +176,7 @@ namespace SeaHaven.Services.Implementation if (workOrder.PrimaryDispatchId.HasValue) { - var existing = await _context.Dispatches - .FirstOrDefaultAsync(d => d.Id == workOrder.PrimaryDispatchId.Value); + var existing = await _mutationData.GetTrackedDispatchByIdAsync(workOrder.PrimaryDispatchId.Value, cancellationToken); if (existing != null) return existing; @@ -230,16 +194,15 @@ namespace SeaHaven.Services.Implementation Status = "Pending", CreatedDate = DateTime.UtcNow }; - _context.Dispatches.Add(created); - await _context.SaveChangesAsync(); + _mutationData.TrackNewDispatch(created); + await _mutationData.SaveAsync(cancellationToken); workOrder.PrimaryDispatchId = created.Id; return created; } private async Task EnsureVendorExistsAsync(int vendorId) { - var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true); - if (!vendorExists) + if (!await _mutationData.VendorExistsAsync(vendorId, CancellationToken.None)) throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); } diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs index 1e71395..2ec8f5b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -1,6 +1,5 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -11,18 +10,18 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderCompletionService : IWorkOrderCompletionService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderCompletionDataService _completionData; private readonly ICompletionDocTemplateDataService _templateData; private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; public WorkOrderCompletionService( - ApplicationDbContext context, + IWorkOrderCompletionDataService completionData, ICompletionDocTemplateDataService templateData, IWorkOrderDetailDataService detailData, IWorkOrderAuditService auditService) { - _context = context; + _completionData = completionData; _templateData = templateData; _detailData = detailData; _auditService = auditService; @@ -46,8 +45,7 @@ namespace SeaHaven.Services.Implementation string fileUrl, string? actorId) { - var workOrder = await _context.workOrders - .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + var workOrder = await _completionData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); @@ -62,7 +60,7 @@ namespace SeaHaven.Services.Implementation if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion)) throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); - _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion; + _completionData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); var oldDocStatus = workOrder.DocStatus?.ToString(); var oldAttachment = workOrder.SignOffAttachment; @@ -79,7 +77,7 @@ namespace SeaHaven.Services.Implementation if (oldDocStatus != DocStatus.Yes.ToString()) await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId); - await _context.SaveChangesAsync(); + await _completionData.SaveAsync(CancellationToken.None); var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType); @@ -124,7 +122,7 @@ namespace SeaHaven.Services.Implementation { ValidateTemplateRequest(request); - var entity = await _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true); + var entity = await _completionData.GetTrackedTemplateAsync(id, CancellationToken.None); if (entity == null) throw new WorkOrderBoardValidationException("NotFound", "Template not found."); diff --git a/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs new file mode 100644 index 0000000..c0e51a7 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs @@ -0,0 +1,433 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderDispatchService : IWorkOrderDispatchService + { + private readonly IWorkOrderDataService _workOrderDataService; + private readonly IDispatchDataService _dispatchDataService; + private readonly IVendorDataService _vendorDataService; + private readonly IUserDataService _userDataService; + private readonly ITaskListTemplateDataService _taskListTemplateDataService; + private readonly ICommentDataService _commentDataService; + private readonly IDispatchEmailPort _emailPort; + private readonly IVendorTokenPort _vendorTokenPort; + private readonly FrontendOptions _frontendOptions; + + public WorkOrderDispatchService( + IWorkOrderDataService workOrderDataService, + IDispatchDataService dispatchDataService, + IVendorDataService vendorDataService, + IUserDataService userDataService, + ITaskListTemplateDataService taskListTemplateDataService, + ICommentDataService commentDataService, + IDispatchEmailPort emailPort, + IVendorTokenPort vendorTokenPort, + IOptions frontendOptions) + { + _workOrderDataService = workOrderDataService; + _dispatchDataService = dispatchDataService; + _vendorDataService = vendorDataService; + _userDataService = userDataService; + _taskListTemplateDataService = taskListTemplateDataService; + _commentDataService = commentDataService; + _emailPort = emailPort; + _vendorTokenPort = vendorTokenPort; + _frontendOptions = frontendOptions.Value; + } + + public async Task DispatchToVendorAsync(DispatchToVendorInput input, CancellationToken cancellationToken) + { + var workOrders = await _workOrderDataService.GetByRequestedIdsWithDetailsAsync( + input.WorkOrderIds, + cancellationToken); + + if (workOrders.Count == 0) + throw new InvalidOperationException("No work orders found"); + + var primaryWO = workOrders[0]; + + var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); + int nextPO = int.TryParse(lastPO, out var po) ? po : 1; + + var lastDSP = await _dispatchDataService.GenerateNextDispatchNumberAsync(); + int nextDSP = lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var dspNum) ? dspNum : 1; + + var currentUser = input.UserId != null ? await _userDataService.GetByIdAsync(input.UserId) : null; + var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : ""; + + var templateItemTexts = await LoadTemplateItemTextsAsync(input.TaskListTemplateId); + + var distinctVendorIds = input.VendorIds.Distinct().ToList(); + var vendors = await _vendorDataService.GetByIdsOrderedAsync(distinctVendorIds, cancellationToken); + + var dispatches = new List(); + + foreach (var vendor in vendors) + { + var poNumber = $"VPO-{nextPO:D5}"; + nextPO++; + var dispatchNumber = $"DSP-{nextDSP:D5}"; + nextDSP++; + + var replyTo = $"wo-{primaryWO.InternalWONumber}-{dispatchNumber}@int.seahaven.com"; + + var dispatch = new Dispatch + { + WorkOrderId = primaryWO.Id, + VendorId = vendor.Id, + DispatchNumber = dispatchNumber, + PONumber = poNumber, + NTEAmount = input.NTEAmount, + Description = input.Description, + Status = "Sent", + ScheduledDate = input.ScheduledDate, + DispatchedAt = DateTime.UtcNow, + ReplyToAddress = replyTo, + DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() + }; + + var checklistItems = BuildChecklistItems(templateItemTexts, input.CustomChecklistItems); + dispatch.ChecklistItems = checklistItems; + + var woListHtml = string.Join("", workOrders.Select(wo => + $"{System.Net.WebUtility.HtmlEncode(wo.InternalWONumber ?? "")}" + + $"{System.Net.WebUtility.HtmlEncode(wo.WorkerOrderTitle ?? "")}" + + $"{System.Net.WebUtility.HtmlEncode(wo.Priority ?? "")}" + + $"{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}")); + + var locationName = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Name ?? ""); + var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address1 ?? ""); + var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {primaryWO.Locations?.Name ?? ""}"; + var descriptionHtml = System.Net.WebUtility.HtmlEncode(input.Description ?? primaryWO.Description ?? ""); + var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); + + var vendorToken = await _vendorTokenPort.GetOrCreateActiveTokenAsync(vendor.Id); + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; + var portalUrl = $"{frontendBase}/v/{vendorToken}/dashboard"; + + var html = $@" +

Work Order Dispatch — {dispatchNumber}

+

Location: {locationName} {locationAddr}

+

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

+

Work Orders

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

Description

+

{descriptionHtml}

+
+ +

{senderNameHtml}
Sea Haven Industries

+

Reply to this email to communicate about this dispatch.

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

{input.Text}


{senderName}
Sea Haven Industries


Reply to this email to respond.

"; + await _emailPort.SendDispatchEmailAsync(dispatch.Vendor.Email, subject, html, dispatch.ReplyToAddress); + } + + return new AddDispatchCommentResult + { + Id = comment.Id, + CreatedDate = comment.CreatedDate, + Commenttext = comment.Commenttext, + CommentType = comment.CommentType, + UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" + }; + } + + public async Task VerifyDispatchAsync(int dispatchId, string userId) + { + var dispatch = await _dispatchDataService.GetByIdAsync(dispatchId); + if (dispatch == null) + return null; + + var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(dispatchId); + var incompleteItems = checklistItems.Count(c => !c.IsCompleted); + + var signoffs = await _dispatchDataService.GetSignoffsAsync(dispatchId); + var signoffList = signoffs.ToList(); + var signoffCount = signoffList.Count; + var existingTypes = signoffList.Select(s => s.SignoffType).ToList(); + + var missing = new List(); + if (incompleteItems > 0) + missing.Add($"{incompleteItems} checklist item(s) incomplete"); + if (signoffCount < 2) + { + if (!existingTypes.Contains("Customer")) missing.Add("Missing Customer sign-off"); + if (!existingTypes.Contains("Vendor")) missing.Add("Missing Vendor sign-off"); + } + + if (missing.Count > 0) + throw new DispatchVerificationException(missing); + + var user = userId != null ? await _userDataService.GetByIdAsync(userId) : null; + var userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; + + dispatch.Status = "Verified"; + dispatch.VerifiedBy = userName; + dispatch.VerifiedAt = DateTime.UtcNow; + + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch.DispatchNumber}", + OldValue = dispatch.Status ?? "", + NewValue = "Verified", + Action = "dispatch_verified", + CreatedAt = DateTime.UtcNow + }); + + await _dispatchDataService.UpdateAsync(dispatch); + + return new VerifyDispatchResult + { + Message = "Dispatch verified", + VerifiedBy = userName, + VerifiedAt = dispatch.VerifiedAt + }; + } + + public async Task AddDispatchSignoffAsync(AddDispatchSignoffInput input) + { + var dispatch = await _dispatchDataService.GetByIdAsync(input.DispatchId); + if (dispatch == null) + return null; + + var existing = await _dispatchDataService.HasSignoffTypeAsync(input.DispatchId, input.SignoffType!); + if (existing) + throw new InvalidOperationException($"{input.SignoffType} sign-off already exists"); + + var signoff = new DispatchSignoff + { + DispatchId = input.DispatchId, + SignoffType = input.SignoffType, + Name = input.Name, + Signature = input.Signature, + SignatureMethod = input.SignatureMethod, + SignedAt = DateTime.UtcNow + }; + await _dispatchDataService.AddSignoffAsync(signoff); + + return new AddDispatchSignoffResult + { + Id = signoff.Id, + SignoffType = signoff.SignoffType, + Name = signoff.Name, + SignedAt = signoff.SignedAt, + SignatureMethod = signoff.SignatureMethod + }; + } + + public async Task UpdateChecklistItemAsync(ChecklistItemUpdateInput input) + { + var item = await _dispatchDataService.GetChecklistItemByIdAsync(input.Id); + if (item == null) + return null; + + item.IsCompleted = input.IsCompleted; + item.CompletedBy = input.IsCompleted ? input.CompletedBy : null; + item.CompletedAt = input.IsCompleted ? DateTime.UtcNow : null; + await _dispatchDataService.UpdateChecklistItemAsync(item); + + return new ChecklistItemUpdateResult + { + Message = "Updated", + Id = item.Id, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }; + } + + public async Task AddChecklistItemAsync(AddChecklistItemInput input) + { + var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(input.DispatchId); + var maxSort = checklistItems.Any() ? checklistItems.Max(c => c.SortOrder) : -1; + + var item = new DispatchChecklistItem + { + DispatchId = input.DispatchId, + WorkOrderId = input.WorkOrderId, + ItemText = input.ItemText, + SortOrder = maxSort + 1, + IsCompleted = false + }; + await _dispatchDataService.AddChecklistItemAsync(item); + + return new AddChecklistItemResult + { + Id = item.Id, + ItemText = item.ItemText, + SortOrder = item.SortOrder, + IsCompleted = item.IsCompleted + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs index 442fbb3..9825181 100644 --- a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs @@ -1,47 +1,25 @@ -using Data.SeaHavenIndustries; -using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class WorkOrderFieldLockService : IWorkOrderFieldLockService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderFieldLockDataService _dataService; - public WorkOrderFieldLockService(ApplicationDbContext context) + public WorkOrderFieldLockService(IWorkOrderFieldLockDataService dataService) { - _context = context; + _dataService = dataService; } - public async Task IsLockedAsync(int workOrderId, string fieldName) - { - if (IsTracked(workOrderId, fieldName)) - return true; + public Task IsLockedAsync(int workOrderId, string fieldName) + => _dataService.IsLockedAsync(workOrderId, fieldName, CancellationToken.None); - return await _context.WorkOrderFieldLocks - .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); - } - - public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId) - { - if (IsTracked(workOrderId, fieldName)) - return; - - if (await IsLockedAsync(workOrderId, fieldName)) - return; - - _context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock - { - WorkOrderId = workOrderId, - FieldName = fieldName, - LockedAt = DateTime.UtcNow, - LockedByUserId = userId - }); - } - - private bool IsTracked(int workOrderId, string fieldName) - => _context.WorkOrderFieldLocks.Local.Any(l => - l.WorkOrderId == workOrderId && - string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase)); + public Task LockFieldAsync(int workOrderId, string fieldName, string? userId) + => _dataService.EnqueueLockAsync( + workOrderId, + fieldName, + userId, + CancellationToken.None); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs index aa0638e..3a8bffb 100644 --- a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs @@ -1,5 +1,5 @@ using Data.SeaHavenIndustries; -using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; @@ -8,18 +8,18 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderIngestService : IWorkOrderIngestService { - private readonly ApplicationDbContext _db; + private readonly IWorkOrderIngestDataService _ingestData; private readonly ISyncFieldMergePolicy _mergePolicy; private readonly IWorkOrderFieldLockService _fieldLocks; private readonly IWorkOrderAuditService _audit; public WorkOrderIngestService( - ApplicationDbContext db, + IWorkOrderIngestDataService ingestData, ISyncFieldMergePolicy mergePolicy, IWorkOrderFieldLockService fieldLocks, IWorkOrderAuditService audit) { - _db = db; + _ingestData = ingestData; _mergePolicy = mergePolicy; _fieldLocks = fieldLocks; _audit = audit; @@ -33,12 +33,7 @@ namespace SeaHaven.Services.Implementation if (items.Count == 0) return result; - var useTransaction = _db.Database.IsRelational(); - await using var transaction = useTransaction - ? await _db.Database.BeginTransactionAsync(cancellationToken) - : null; - - try + await _ingestData.ExecuteTransactionalAsync(async cancellationToken => { var nextSeed = await AllocateNextWoSeedAsync(cancellationToken); @@ -59,16 +54,8 @@ namespace SeaHaven.Services.Implementation result.Updated++; } - await _db.SaveChangesAsync(cancellationToken); - if (transaction != null) - await transaction.CommitAsync(cancellationToken); - } - catch - { - if (transaction != null) - await transaction.RollbackAsync(cancellationToken); - throw; - } + await _ingestData.SaveAsync(cancellationToken); + }, cancellationToken); return result; } @@ -78,8 +65,7 @@ namespace SeaHaven.Services.Implementation int nextSeed, CancellationToken cancellationToken) { - var existing = await _db.workOrders - .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == item.ExternalWorkOrderId, cancellationToken); + var existing = await _ingestData.GetTrackedByExternalIdAsync(item.ExternalWorkOrderId, cancellationToken); var locationId = await ResolveLocationIdAsync( item.SiteCode, item.Building, item.Address, cancellationToken); @@ -108,8 +94,8 @@ namespace SeaHaven.Services.Implementation CreatedDate = item.CreatedAt ?? DateTime.UtcNow, istemplate = false }; - _db.workOrders.Add(wo); - await _db.SaveChangesAsync(cancellationToken); + _ingestData.TrackWorkOrder(wo); + await _ingestData.SaveAsync(cancellationToken); return (new WorkOrderIngestItemResultDto { @@ -173,7 +159,7 @@ namespace SeaHaven.Services.Implementation private async Task AllocateNextWoSeedAsync(CancellationToken cancellationToken) { - var maxId = await _db.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0; + var maxId = await _ingestData.GetMaxWorkOrderIdAsync(cancellationToken); return Math.Max(maxId + 1, 1); } @@ -194,8 +180,7 @@ namespace SeaHaven.Services.Implementation return null; var matchCode = siteCode ?? building; - var existing = await _db.Locations - .FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken); + var existing = await _ingestData.FindLocationAsync(matchCode, cancellationToken); if (existing != null) return existing.Id; @@ -208,8 +193,7 @@ namespace SeaHaven.Services.Implementation Status = "Active" }; - _db.Locations.Add(location); - await _db.SaveChangesAsync(cancellationToken); + await _ingestData.AddAndSaveLocationAsync(location, cancellationToken); return location.Id; } } diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 8de3380..81c2614 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -1,6 +1,5 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -11,12 +10,12 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderMediaService : IWorkOrderMediaService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderMediaDataService _mediaData; private readonly IWorkOrderDetailDataService _detailData; - public WorkOrderMediaService(ApplicationDbContext context, IWorkOrderDetailDataService detailData) + public WorkOrderMediaService(IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData) { - _context = context; + _mediaData = mediaData; _detailData = detailData; } @@ -42,7 +41,7 @@ namespace SeaHaven.Services.Implementation if (!await _detailData.ExistsAsync(workOrderId)) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId); + var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); @@ -59,7 +58,7 @@ namespace SeaHaven.Services.Implementation if (category == WorkOrderMediaCategory.Before) { workOrder.BeforPhotoAttachment = fileUrl; - await _context.SaveChangesAsync(); + await _mediaData.SaveAsync(CancellationToken.None); return new WorkOrderMediaFileDto { Id = -1, @@ -72,7 +71,7 @@ namespace SeaHaven.Services.Implementation if (category == WorkOrderMediaCategory.After) { workOrder.AfterPhotoAttachment = fileUrl; - await _context.SaveChangesAsync(); + await _mediaData.SaveAsync(CancellationToken.None); return new WorkOrderMediaFileDto { Id = -2, @@ -91,8 +90,8 @@ namespace SeaHaven.Services.Implementation createdby = actorId }; - _context.workOrderAttachments.Add(attachment); - await _context.SaveChangesAsync(); + _mediaData.TrackAttachment(attachment); + await _mediaData.SaveAsync(CancellationToken.None); return new WorkOrderMediaFileDto { @@ -112,15 +111,14 @@ namespace SeaHaven.Services.Implementation if (!await _detailData.ExistsAsync(workOrderId)) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId); + var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); - var attachment = await _context.workOrderAttachments - .FirstOrDefaultAsync(a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true); + var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None); if (attachment == null) throw new WorkOrderBoardValidationException("NotFound", "Media not found."); @@ -128,7 +126,7 @@ namespace SeaHaven.Services.Implementation attachment.IsDeleted = true; attachment.DeletionTime = DateTime.UtcNow; attachment.DeleterUserId = actorId; - await _context.SaveChangesAsync(); + await _mediaData.SaveAsync(CancellationToken.None); } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs index 11656ed..aa85017 100644 --- a/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs @@ -1,6 +1,5 @@ -using Data.SeaHavenIndustries; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -8,16 +7,16 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderOpsHealthService : IWorkOrderOpsHealthService { - private readonly ApplicationDbContext _db; - private readonly WorkOrderJobRunStateAccessor _runState; + private readonly IWorkOrderOpsHealthDataService _healthData; + private readonly IWorkOrderJobRunStateAccessor _runState; private readonly WorkOrderOpsHealthOptions _options; public WorkOrderOpsHealthService( - ApplicationDbContext db, - WorkOrderJobRunStateAccessor runState, + IWorkOrderOpsHealthDataService healthData, + IWorkOrderJobRunStateAccessor runState, IOptions options) { - _db = db; + _healthData = healthData; _runState = runState; _options = options.Value; } @@ -26,18 +25,11 @@ namespace SeaHaven.Services.Implementation { var since = DateTime.UtcNow.AddHours(-24); - var syncRejected = await _db.WorkOrderAuditLogs - .CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken); - - var fieldLocks = await _db.WorkOrderFieldLocks.CountAsync(cancellationToken); + var syncRejected = await _healthData.CountSyncRejectedSinceAsync(since, cancellationToken); + var fieldLocks = await _healthData.CountFieldLocksAsync(cancellationToken); var state = _runState.State; - DateTime? ledgerWeekRolled = null; - if (await _db.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken)) - { - ledgerWeekRolled = await _db.WorkOrderWeekRolledLedgers - .MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken); - } + var ledgerWeekRolled = await _healthData.GetLastWeekRolledProcessedAtAsync(cancellationToken); return new WorkOrderOpsHealthDto { @@ -66,16 +58,9 @@ namespace SeaHaven.Services.Implementation } /// Shared in-memory job run timestamps (API hosted services + ops health). - public class WorkOrderJobRunStateAccessor + public class WorkOrderJobRunStateAccessor : IWorkOrderJobRunStateAccessor { public WorkOrderJobRunState State { get; } = new(); } - public class WorkOrderJobRunState - { - public DateTime? LastWeekRolledRunUtc { get; set; } - public DateTime? LastPastDueCacheRunUtc { get; set; } - public string? LastWeekRolledError { get; set; } - public string? LastPastDueCacheError { get; set; } - } } diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 19fefc3..88632dc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; @@ -11,15 +12,27 @@ namespace SeaHaven.Services.Implementation public class WorkOrderService : IWorkOrderService { private readonly IWorkOrderDataService _workOrderDataService; + private readonly ICommentDataService _commentDataService; + private readonly IUserDataService _userDataService; + private readonly IQuotesDataService _quotesDataService; + private readonly IFileStoragePort _fileStorage; private readonly ICreateWorkOrderValidation _createValidator; private readonly IUpdateWorkOrderValidation _updateValidator; public WorkOrderService( IWorkOrderDataService workOrderDataService, + ICommentDataService commentDataService, + IUserDataService userDataService, + IQuotesDataService quotesDataService, + IFileStoragePort fileStorage, ICreateWorkOrderValidation createValidator, IUpdateWorkOrderValidation updateValidator) { _workOrderDataService = workOrderDataService; + _commentDataService = commentDataService; + _userDataService = userDataService; + _quotesDataService = quotesDataService; + _fileStorage = fileStorage; _createValidator = createValidator; _updateValidator = updateValidator; } @@ -53,13 +66,12 @@ namespace SeaHaven.Services.Implementation PageSize = pageSize }; } + public async Task CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); if (!validationResult.IsValid) - { throw new ValidationException(validationResult.Errors); - } var workOrder = CreateWorkOrderDTO.MapToEntity(dto); workOrder.createdby = userId; @@ -67,9 +79,7 @@ namespace SeaHaven.Services.Implementation workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync(); var saved = await _workOrderDataService.AddAsync(workOrder); - await LogAuditAsync(saved.Id, userId, AuditAction.Create, "Work order created"); - return saved.Id; } @@ -77,9 +87,7 @@ namespace SeaHaven.Services.Implementation { var validationResult = await _updateValidator.ValidateAsync(dto); if (!validationResult.IsValid) - { throw new ValidationException(validationResult.Errors); - } var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id); if (existingWorkOrder == null) @@ -92,9 +100,7 @@ namespace SeaHaven.Services.Implementation await _workOrderDataService.UpdateAsync(existingWorkOrder); if (changes.Any()) - { await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes)); - } return true; } @@ -108,61 +114,502 @@ namespace SeaHaven.Services.Implementation await _workOrderDataService.DeleteWorkOrderContactsAsync(id); await _workOrderDataService.DeleteWorkOrderCategoriesAsync(id); await _workOrderDataService.DeleteAsync(id); - await LogAuditAsync(id, userId, AuditAction.Delete, "Work order deleted"); + return true; + } + + public async Task WorkOrderExistsAsync(int id) => await _workOrderDataService.ExistsAsync(id); + public async Task GenerateNextInternalWONumberAsync() => await _workOrderDataService.GenerateNextInternalWONumberAsync(); + public async Task GetTotalWorkOrderCountAsync() => await _workOrderDataService.CountAsync(); + + public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + int page, int pageSize, string? search, string? status, string? assignee, + int? locationId, string? sortBy, string? sortDir) + { + return await _workOrderDataService.GetWorkOrderListPagedAsync( + page, pageSize, search, status, assignee, locationId, sortBy, sortDir); + } + + public async Task GetWorkOrderDetailAsync(int id) + { + return await _workOrderDataService.GetWorkOrderDetailAsync(id); + } + + public async Task GetFilteredWorkOrdersAsync( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, + string sort, string sortby, int page, int pageSize) + { + var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); + + var data = allWorkOrders.AsEnumerable(); + + if (!string.IsNullOrEmpty(search)) + { + var s = search.ToLower(); + data = data.Where(w => + (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || + (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || + (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(s))); + } + + if (assignto != null && assignto.Length > 0 && assignto[0] != "" && assignto[0] != null) + data = data.Where(w => assignto.Contains(w.AssignTo)); + + if (location != null && location.Length > 0 && location[0] != 0) + data = data.Where(w => location.Contains(w.LocationId ?? 0)); + + if (priority != null && priority.Length > 0 && priority[0] != "" && priority[0] != null) + data = data.Where(w => priority.Contains(w.Priority)); + + if (status != null && status.Length > 0 && status[0] != "" && status[0] != null) + data = data.Where(w => status.Contains(w.Status)); + + var dataList = data.ToList(); + + dataList = dataList.Where(w => + duedate == null || duedate.Length == 0 || duedate[0] == null || + duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, DateTime.UtcNow.Date))).ToList(); + + if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") + { + switch (sortby) + { + case "Name": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.WorkerOrderTitle).ToList() + : dataList.OrderByDescending(w => w.WorkerOrderTitle).ToList(); + break; + case "Due Date": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.DueDate).ToList() + : dataList.OrderByDescending(w => w.DueDate).ToList(); + break; + case "Severity": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.Priority).ToList() + : dataList.OrderByDescending(w => w.Priority).ToList(); + break; + } + } + + var totalCount = dataList.Count; + var pagedData = dataList + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(s => new FilteredWorkOrderReadModel + { + Id = s.Id, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + Status = s.Status, + Priority = s.Priority, + Name = s.Locations != null ? s.Locations.Name : "" + }) + .OrderByDescending(d => d.Name) + .ToList(); + + return new FilteredWorkOrderPageDTO + { + Data = pagedData, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + } + + public async Task GetFilteredWorkOrders2Async( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, + string sort, string sortby, int page, int pageSize) + { + location ??= Array.Empty(); + priority ??= Array.Empty(); + status ??= Array.Empty(); + duedate ??= Array.Empty(); + search ??= string.Empty; + + var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); + var totalCount = allWorkOrders.Count; + + var currentDate = DateTime.UtcNow.Date; + var data = allWorkOrders.Where(w => + (((w.WorkerOrderTitle ?? "").Contains(search, StringComparison.OrdinalIgnoreCase) || + (w.WorkerOrderNumber ?? "").Contains(search, StringComparison.OrdinalIgnoreCase) || + (w.Locations != null && + (w.Locations.Title ?? "").Contains(search, StringComparison.OrdinalIgnoreCase))) && + (assignto.Length == 0 || assignto.Contains(w.AssignTo)) && + (location.Length == 0 || (w.LocationId.HasValue && location.Contains(w.LocationId.Value))) && + (priority.Length == 0 || priority.Contains(w.Priority)) && + (status.Length == 0 || status.Contains(w.Status)))).ToList(); + + data = data.Where(w => + duedate.Length == 0 || + duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, currentDate))).ToList(); + + if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") + { + switch (sortby) + { + case "Name": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.WorkerOrderTitle).ToList() + : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); + break; + case "Due Date": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.DueDate).ToList() + : data.OrderByDescending(w => w.DueDate).ToList(); + break; + case "Severity": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.Priority).ToList() + : data.OrderByDescending(w => w.Priority).ToList(); + break; + } + } + + var newdata = data + .Select(s => new FilteredWorkOrderReadModel + { + Id = s.Id, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + Status = s.Status, + Priority = s.Priority, + Name = s.Locations != null ? s.Locations.Name : "" + }) + .OrderByDescending(d => d.Name) + .ToList() + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); + + return new FilteredWorkOrderPageDTO + { + Data = newdata, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + } + + public async Task ChangeStatusAsync(int id, string status, string userId) + { + var workOrder = await _workOrderDataService.GetByIdAsync(id); + if (workOrder == null) return null; + + if (workOrder.Status != status) + { + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = id, + UserId = userId, + FieldName = "Status", + OldValue = workOrder.Status ?? "", + NewValue = status, + Action = AuditAction.StatusChange.ToString(), + CreatedAt = DateTime.UtcNow + }); + } + + workOrder.Status = status; + await _workOrderDataService.UpdateAsync(workOrder); + + return WorkOrderDTO.MapToModel(workOrder); + } + + public async Task ChangeAssignmentAsync(int id, string? assignTo, string userId) + { + var workOrder = await _workOrderDataService.GetByIdAsync(id); + if (workOrder == null) return null; + + if (workOrder.AssignTo != assignTo) + { + var oldUser = workOrder.AssignTo != null ? await _userDataService.GetByIdAsync(workOrder.AssignTo) : null; + var newUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; + + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = id, + UserId = userId, + FieldName = "AssignedTo", + OldValue = oldUser != null ? (oldUser.FirstName + " " + oldUser.LastName).Trim() : "", + NewValue = newUser != null ? (newUser.FirstName + " " + newUser.LastName).Trim() : "Unassigned", + Action = AuditAction.AssignmentChange.ToString(), + CreatedAt = DateTime.UtcNow + }); + } + + workOrder.AssignTo = assignTo; + await _workOrderDataService.UpdateAsync(workOrder); + + var assignedUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; + return new ChangeAssignmentResult + { + Message = "Updated Successfully", + AssignedTo = assignedUser != null ? (assignedUser.FirstName + " " + assignedUser.LastName).Trim() : "" + }; + } + + public async Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId) + { + var createDto = new CreateWorkOrderDTO + { + WorkerOrderTitle = input.WorkerOrderTitle, + Description = input.Description, + Status = WorkOrderStatus.Open, + Priority = Enum.TryParse(input.Priority, out var p) ? p : WorkOrderPriority.Medium, + LocationId = input.LocationId, + AssignTo = input.AssignTo, + DueDate = input.DueDate + }; + + var workOrderId = await CreateWorkOrderAsync(createDto, userId); + + if (input.ContactIds != null && input.ContactIds.Any()) + await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds); + + if (input.CategoryIds != null && input.CategoryIds.Any()) + await _workOrderDataService.AddWorkOrderCategoriesAsync(workOrderId, input.CategoryIds); + + await SaveAttachmentsAndPhotos(workOrderId, input.Attachments, input.BeforPhotoAttachment, + input.AfterPhotoAttachment, input.SignOffAttachment); + + return workOrderId; + } + + public async Task UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId) + { + var updateDto = new UpdateWorkOrderDTO + { + WorkerOrderTitle = input.WorkerOrderTitle, + Description = input.Description, + Status = Enum.TryParse(input.Status, out var st) ? st : null, + Priority = Enum.TryParse(input.Priority, out var pr) ? pr : null, + LocationId = input.LocationId, + AssignTo = input.AssignTo, + DueDate = input.DueDate, + CompletedDate = input.CompletedDate + }; + + var success = await UpdateWorkOrderAsync(input.Id, updateDto, userId); + if (!success) return false; + + await _workOrderDataService.DeleteWorkOrderContactsAsync(input.Id); + await _workOrderDataService.DeleteWorkOrderCategoriesAsync(input.Id); + + if (input.ContactIds != null && input.ContactIds.Any()) + await _workOrderDataService.AddWorkOrderContactsAsync(input.Id, input.ContactIds); + + if (input.CategoryIds != null && input.CategoryIds.Any()) + await _workOrderDataService.AddWorkOrderCategoriesAsync(input.Id, input.CategoryIds); + + await SaveAttachmentsAndPhotos(input.Id, input.Attachments, input.BeforPhotoAttachment, + input.AfterPhotoAttachment, input.SignOffAttachment); return true; } - public async Task WorkOrderExistsAsync(int id) + public async Task DeleteWorkOrderCascadeAsync(int id, string userId) { - return await _workOrderDataService.ExistsAsync(id); + var success = await DeleteWorkOrderAsync(id, userId); + if (!success) return false; + + await _quotesDataService.UnlinkFromWorkOrderAsync(id); + + var comments = await _commentDataService.GetByWorkOrderIdAsync(id); + foreach (var comment in comments) + await _commentDataService.DeleteAsync(comment.Id); + + return true; } - public async Task GenerateNextInternalWONumberAsync() + public async Task AddCommentAsync(AddCommentInput input, string userId) { - return await _workOrderDataService.GenerateNextInternalWONumberAsync(); + var comment = new Comments + { + UserId = userId, + WorkerOrderId = input.WorkorderId, + Commenttext = input.Text, + CommentType = input.CommentType ?? "vendor" + }; + + if (input.Document != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(input.Document); + comment.Documents = fileUrl; + } + + return await _commentDataService.AddAsync(comment); } - public async Task GetTotalWorkOrderCountAsync() + public async Task AddCommentJsonAsync(AddCommentInput input, string userId) { - return await _workOrderDataService.CountAsync(); + var comment = new Comments + { + UserId = userId, + WorkerOrderId = input.WorkorderId, + Commenttext = input.Text, + CommentType = input.CommentType ?? "internal" + }; + + var saved = await _commentDataService.AddAsync(comment); + var user = await _userDataService.GetByIdAsync(userId); + + return new CommentResult + { + Id = saved.Id, + CreatedDate = saved.CreatedDate, + Commenttext = saved.Commenttext, + CommentType = saved.CommentType, + UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" + }; + } + + public async Task> GetCommentsAsync() + { + var comments = await _commentDataService.GetAllAsync(); + return comments.Select(s => new CommentListItemReadModel + { + Id = s.Id, + Commenttext = s.Commenttext, + FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", + Documents = s.Documents + }).ToList(); + } + + public async Task> GetCommentsByWorkorderIdAsync(int woid) + { + var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); + return comments.Select(s => new CommentListItemReadModel + { + Id = s.Id, + Commenttext = s.Commenttext, + FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", + Documents = s.Documents + }).ToList(); + } + + public async Task> GetWorkordersDDAsync() + { + var data = await _workOrderDataService.GetAllAsync(); + return data.Where(w => w.istemplate != true).ToList(); + } + + public async Task> GetWorkordersAsync() + { + var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); + return workOrders.Select(s => new WorkorderFilterVM + { + Id = s.Id, + Priority = s.Priority, + Status = s.Status, + WorkerOrderNumber = s.WorkerOrderNumber, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "", + LocationName = s.Locations != null ? s.Locations.Name : "", + LocationId = s.LocationId, + PO = s.PO, + TT = s.TT + }).ToList(); + } + + private async Task SaveAttachmentsAndPhotos(int workOrderId, + List? attachments, + Microsoft.AspNetCore.Http.IFormFile? beforPhoto, + Microsoft.AspNetCore.Http.IFormFile? afterPhoto, + Microsoft.AspNetCore.Http.IFormFile? signOffPhoto) + { + if (attachments != null) + { + foreach (var attachment in attachments) + { + var fileUrl = await _fileStorage.SaveFileAsync(attachment); + await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl + }); + } + } + + if (beforPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(beforPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.BeforPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + + if (afterPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(afterPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.AfterPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + + if (signOffPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(signOffPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.SignOffAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + } + + private static bool CheckDueDate(string dd, DateTime dueDate, DateTime currentDate) + { + return dd switch + { + "Today" => dueDate == currentDate, + "Tomorrow" => dueDate == currentDate.AddDays(1), + "Next Week" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddDays(7), + "Next Month" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddMonths(1), + "Over Due" => dueDate < currentDate, + _ => false + }; } private List TrackChanges(WorkOrder existing, UpdateWorkOrderDTO dto) { var changes = new List(); - if (dto.WorkerOrderTitle != null && dto.WorkerOrderTitle != existing.WorkerOrderTitle) changes.Add($"Title: {existing.WorkerOrderTitle} → {dto.WorkerOrderTitle}"); - if (dto.Status.HasValue && dto.Status.ToString() != existing.Status) changes.Add($"Status: {existing.Status} → {dto.Status}"); - if (dto.Priority.HasValue && dto.Priority.ToString() != existing.Priority) changes.Add($"Priority: {existing.Priority} → {dto.Priority}"); - if (dto.AssignTo != null && dto.AssignTo != existing.AssignTo) changes.Add($"Assigned To: {existing.AssignTo} → {dto.AssignTo}"); - if (dto.DueDate.HasValue && dto.DueDate != existing.DueDate) changes.Add($"Due Date: {existing.DueDate:yyyy-MM-dd} → {dto.DueDate:yyyy-MM-dd}"); - return changes; } private async Task LogAuditAsync(int workOrderId, string userId, AuditAction action, string description) { - var auditLog = new WorkOrderAuditLog + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = workOrderId, Action = action.ToString(), FieldName = description, UserId = userId, CreatedAt = DateTime.UtcNow - }; - - await _workOrderDataService.AddAuditLogAsync(auditLog); + }); } } -} \ No newline at end of file +} diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs new file mode 100644 index 0000000..426eb5f --- /dev/null +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -0,0 +1,14 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IAuthenticationService + { + Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); + Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken); + Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); + Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); + Task VerifyCodeAsync(string code, CancellationToken cancellationToken); + Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ICalendarService.cs b/SeaHaven.Services/Interfaces/ICalendarService.cs new file mode 100644 index 0000000..3bc1cbc --- /dev/null +++ b/SeaHaven.Services/Interfaces/ICalendarService.cs @@ -0,0 +1,14 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ICalendarService + { + Task CreateEventAsync(EditEventInput input, CancellationToken cancellationToken); + Task UpdateEventAsync(EditEventInput input, CancellationToken cancellationToken); + Task GetEventByIdAsync(int id, CancellationToken cancellationToken); + Task DeleteEventAsync(int id, CancellationToken cancellationToken); + Task GetEventListAsync(string? search, int page, int pageSize, CancellationToken cancellationToken); + Task> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ICommonService.cs b/SeaHaven.Services/Interfaces/ICommonService.cs new file mode 100644 index 0000000..4b27e9a --- /dev/null +++ b/SeaHaven.Services/Interfaces/ICommonService.cs @@ -0,0 +1,13 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ICommonService + { + Task GetUsersPagedAsync(string? search, int page, int pageSize, CancellationToken cancellationToken); + Task> GetRolesAsync(CancellationToken cancellationToken); + Task> GetSiteNamesAsync(CancellationToken cancellationToken); + Task> GetContactsAsync(CancellationToken cancellationToken); + Task> GetCategoriesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs new file mode 100644 index 0000000..bfc2e2d --- /dev/null +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IDashboardService + { + Task GetStatsAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IDispatchService.cs b/SeaHaven.Services/Interfaces/IDispatchService.cs index a650389..1f1da0a 100644 --- a/SeaHaven.Services/Interfaces/IDispatchService.cs +++ b/SeaHaven.Services/Interfaces/IDispatchService.cs @@ -15,5 +15,8 @@ namespace SeaHaven.Services.Interfaces Task DispatchExistsAsync(int id); Task GenerateNextDispatchNumberAsync(); Task GetTotalDispatchCountAsync(); + + Task GetDispatchListAsync(string? search, string? status, int? vendorId, DateTime? dateFrom, DateTime? dateTo, string? sortBy, string? sortDir, int page, int pageSize, CancellationToken cancellationToken); + Task GetDispatchDetailAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IDropdownOptionsService.cs b/SeaHaven.Services/Interfaces/IDropdownOptionsService.cs new file mode 100644 index 0000000..3eb291f --- /dev/null +++ b/SeaHaven.Services/Interfaces/IDropdownOptionsService.cs @@ -0,0 +1,13 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IDropdownOptionsService + { + Task> GetAllAsync(string? category, CancellationToken cancellationToken); + Task> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken); + Task CreateAsync(DropdownOptionDTO option, CancellationToken cancellationToken); + Task UpdateAsync(int id, DropdownOptionDTO option, CancellationToken cancellationToken); + Task DeleteAsync(int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IEmailSender.cs b/SeaHaven.Services/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..3d2f68f --- /dev/null +++ b/SeaHaven.Services/Interfaces/IEmailSender.cs @@ -0,0 +1,11 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// Outbound email port. Implemented in the API layer so the service layer + /// can orchestrate email without depending on the transport (SendGrid). + /// + public interface IEmailSender + { + Task SendEmailAsync(string emailTo, string subject, string body); + } +} diff --git a/SeaHaven.Services/Interfaces/IFollowUpService.cs b/SeaHaven.Services/Interfaces/IFollowUpService.cs index ae0b3dd..0b2fd94 100644 --- a/SeaHaven.Services/Interfaces/IFollowUpService.cs +++ b/SeaHaven.Services/Interfaces/IFollowUpService.cs @@ -14,5 +14,12 @@ namespace SeaHaven.Services.Interfaces Task DeleteFollowUpAsync(int id, string userId); Task FollowUpExistsAsync(int id); Task GetTotalFollowUpCountAsync(); + + Task> GetFollowUpListAsync(int page, int pageSize, string? search, string? status, CancellationToken cancellationToken); + Task GetFollowUpDetailAsync(int id, CancellationToken cancellationToken); + Task CreateOpenFollowUpAsync(FollowUpCreateRequestDTO dto, string userId, CancellationToken cancellationToken); + Task UpdateFollowUpFieldsAsync(int id, FollowUpUpdateRequestDTO dto, CancellationToken cancellationToken); + Task SetFollowUpStatusAsync(int id, string status, CancellationToken cancellationToken); + Task RemoveFollowUpAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index dd70421..21d9120 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -15,5 +15,11 @@ namespace SeaHaven.Services.Interfaces Task DeleteLocationAsync(int id, string userId); Task LocationExistsAsync(int id); Task GetTotalLocationCountAsync(); + + Task> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + Task GetLocationDetailAsync(int id, CancellationToken cancellationToken); + Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken); + Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken); + Task DeleteLocationByIdAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IQuotesService.cs b/SeaHaven.Services/Interfaces/IQuotesService.cs new file mode 100644 index 0000000..13c2074 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IQuotesService.cs @@ -0,0 +1,16 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IQuotesService + { + Task CreateQuoteAsync(CreateQuoteInput input, CancellationToken cancellationToken); + Task UpdateQuoteAsync(UpdateQuoteInput input, CancellationToken cancellationToken); + Task DeleteQuoteAsync(int id, CancellationToken cancellationToken); + Task GetQuotesPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken); + Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken); + Task GetQuoteDetailsByIdAsync(int id, CancellationToken cancellationToken); + Task GetReportingAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs new file mode 100644 index 0000000..aae1383 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Http; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderJobRunStateAccessor + { + WorkOrderJobRunState State { get; } + } + + public sealed class WorkOrderJobRunState + { + public DateTime? LastWeekRolledRunUtc { get; set; } + public DateTime? LastPastDueCacheRunUtc { get; set; } + public string? LastWeekRolledError { get; set; } + public string? LastPastDueCacheError { get; set; } + } + + public interface IFileStoragePort + { + Task SaveFileAsync(IFormFile file); + } + + /// + /// Narrow vendor completion-document storage port. Implemented in the API layer so the + /// service layer can persist/read uploaded vendor documents without owning filesystem paths. + /// Path resolution is shared with the scan worker to avoid duplicate rules. + /// + public interface IVendorDocumentStoragePort + { + Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken); + Stream OpenRead(int vendorId, int dispatchId, string storedFileName); + void Delete(int vendorId, int dispatchId, string storedFileName); + } + + public interface IDispatchEmailPort + { + Task SendDispatchEmailAsync(string emailTo, string subject, string htmlBody, string? replyTo); + } + + public interface IVendorTokenPort + { + Task GetOrCreateActiveTokenAsync(int vendorId); + } +} diff --git a/SeaHaven.Services/Interfaces/ISettingsService.cs b/SeaHaven.Services/Interfaces/ISettingsService.cs new file mode 100644 index 0000000..6b920b8 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISettingsService.cs @@ -0,0 +1,15 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ISettingsService + { + Task AddCategoryAsync(string? name, CancellationToken cancellationToken); + Task UpdateCategoryAsync(int? id, string? name, CancellationToken cancellationToken); + Task DeleteCategoryAsync(int id, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int Total)> GetCategoryListAsync(int page, int pageSize, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int Total)> GetTemplatesAsync(int page, int pageSize, CancellationToken cancellationToken); + Task<(IReadOnlyList Users, int Total)> GetUsersAsync(string? search, int page, int pageSize, CancellationToken cancellationToken); + Task> GetRolesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ISyncExternalSource.cs b/SeaHaven.Services/Interfaces/ISyncExternalSource.cs new file mode 100644 index 0000000..849927b --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISyncExternalSource.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface ISyncExternalSource + { + IAsyncEnumerable> ScanWorkOrdersAsync(CancellationToken cancellationToken); + IAsyncEnumerable> ScanCommentsAsync(CancellationToken cancellationToken); + IAsyncEnumerable> ScanVendorRepliesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ISyncService.cs b/SeaHaven.Services/Interfaces/ISyncService.cs new file mode 100644 index 0000000..b8f0759 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISyncService.cs @@ -0,0 +1,15 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ISyncService + { + Task SyncWorkOrdersAsync(CancellationToken cancellationToken); + Task SyncCommentsAsync(CancellationToken cancellationToken); + Task BackfillInternalWONumbersAsync(CancellationToken cancellationToken); + Task BackfillDispatchNumbersAsync(CancellationToken cancellationToken); + Task SyncVendorRepliesAsync(CancellationToken cancellationToken); + Task BackfillCommentTypesAsync(CancellationToken cancellationToken); + Task SyncAllAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ITaskListTemplateService.cs b/SeaHaven.Services/Interfaces/ITaskListTemplateService.cs new file mode 100644 index 0000000..596aa2f --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITaskListTemplateService.cs @@ -0,0 +1,13 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ITaskListTemplateService + { + Task> GetAllAsync(CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task CreateAsync(TaskListTemplateInput input, CancellationToken cancellationToken); + Task UpdateAsync(int id, TaskListTemplateInput input, CancellationToken cancellationToken); + Task DeleteAsync(int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IUpliftService.cs b/SeaHaven.Services/Interfaces/IUpliftService.cs new file mode 100644 index 0000000..3fb0b53 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IUpliftService.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IUpliftService + { + Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); + Task> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken); + Task ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + bool CanApprove(ClaimsPrincipal user, int tier); + } +} diff --git a/SeaHaven.Services/Interfaces/IUserService.cs b/SeaHaven.Services/Interfaces/IUserService.cs new file mode 100644 index 0000000..9f47410 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IUserService.cs @@ -0,0 +1,14 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IUserService + { + Task> GetUsersAsync(CancellationToken cancellationToken); + Task AddUserAsync(AddUserRequestDTO dto, CancellationToken cancellationToken); + Task EditUserAsync(EditUserRequestDTO dto, CancellationToken cancellationToken); + Task DeleteUserAsync(string id, CancellationToken cancellationToken); + Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken); + Task> GetUserProfileAsync(string userId, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorOperationsService.cs b/SeaHaven.Services/Interfaces/IVendorOperationsService.cs new file mode 100644 index 0000000..ef08df9 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorOperationsService.cs @@ -0,0 +1,18 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorOperationsService + { + Task GetNotificationsAsync(CancellationToken cancellationToken); + Task> GetAvailabilityAsync(DateTime? start, DateTime? end, int? locationId, string? trade, CancellationToken cancellationToken); + Task> UpdateAvailabilityAsync(int vendorId, string? status, string actor, CancellationToken cancellationToken); + Task> GetSitePreferencesAsync(int locationId, CancellationToken cancellationToken); + Task>> ReplaceSitePreferencesAsync(int locationId, IReadOnlyList body, string actor, CancellationToken cancellationToken); + Task> ReviewDocumentAsync(int documentId, DocumentReviewRequest body, string actor, CancellationToken cancellationToken); + Task> UpdateCommercialStatusAsync(int dispatchId, CommercialStatusRequest body, string actor, CancellationToken cancellationToken); + Task> AssignVendorAsync(int workOrderId, VendorAssignmentRequest body, string actor, CancellationToken cancellationToken); + Task> GetInsightRowsAsync(DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken); + DateTime UtcNow { get; } + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorPortalService.cs b/SeaHaven.Services/Interfaces/IVendorPortalService.cs new file mode 100644 index 0000000..b60edfd --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorPortalService.cs @@ -0,0 +1,21 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorPortalService + { + Task ResolveSessionAsync(string? token, CancellationToken cancellationToken); + Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken); + Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken); + Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken); + Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken); + Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken); + Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken); + Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken); + Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken); + Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken); + Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken); + Task UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken); + Task DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs b/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs new file mode 100644 index 0000000..50a4e53 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs @@ -0,0 +1,12 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorPortalTokenService + { + Task GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken); + Task ResolveSessionAsync(string? token, CancellationToken cancellationToken); + Task RotateAsync(int vendorId, CancellationToken cancellationToken); + Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 2ab4c67..18dd029 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -23,5 +23,10 @@ namespace SeaHaven.Services.Interfaces Task GetTotalVendorCountAsync(); Task GetDeactivationImpactAsync(int vendorId); Task UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId); + Task> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken); + Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken); + Task GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken); + Task RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken); + Task RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs new file mode 100644 index 0000000..f5d0ae3 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs @@ -0,0 +1,18 @@ +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderDispatchService + { + Task DispatchToVendorAsync(DispatchToVendorInput input, CancellationToken cancellationToken); + Task> GetDispatchesAsync(int workOrderId); + Task GetDispatchDetailAsync(int id); + Task UpdateDispatchAsync(UpdateDispatchInput input); + Task AddDispatchCommentAsync(AddDispatchCommentInput input); + Task VerifyDispatchAsync(int dispatchId, string userId); + Task AddDispatchSignoffAsync(AddDispatchSignoffInput input); + Task UpdateChecklistItemAsync(ChecklistItemUpdateInput input); + Task AddChecklistItemAsync(AddChecklistItemInput input); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderService.cs b/SeaHaven.Services/Interfaces/IWorkOrderService.cs index 913adcf..3c60e46 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderService.cs @@ -1,3 +1,7 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces @@ -14,5 +18,27 @@ namespace SeaHaven.Services.Interfaces Task WorkOrderExistsAsync(int id); Task GenerateNextInternalWONumberAsync(); Task GetTotalWorkOrderCountAsync(); + + Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + int page, int pageSize, string? search, string? status, string? assignee, + int? locationId, string? sortBy, string? sortDir); + Task GetWorkOrderDetailAsync(int id); + Task GetFilteredWorkOrdersAsync( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); + Task GetFilteredWorkOrders2Async( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); + Task ChangeStatusAsync(int id, string status, string userId); + Task ChangeAssignmentAsync(int id, string? assignTo, string userId); + Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId); + Task UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId); + Task DeleteWorkOrderCascadeAsync(int id, string userId); + Task AddCommentAsync(AddCommentInput input, string userId); + Task AddCommentJsonAsync(AddCommentInput input, string userId); + Task> GetCommentsAsync(); + Task> GetCommentsByWorkorderIdAsync(int woid); + Task> GetWorkordersDDAsync(); + Task> GetWorkordersAsync(); } } diff --git a/SeaHaven.Services/Interfaces/IZipCodeDistance.cs b/SeaHaven.Services/Interfaces/IZipCodeDistance.cs new file mode 100644 index 0000000..d69ab50 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IZipCodeDistance.cs @@ -0,0 +1,7 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface IZipCodeDistance + { + double? GetDistanceMiles(string? zip1, string? zip2); + } +} diff --git a/SeaHaven.Services/SeaHaven.Services.csproj b/SeaHaven.Services/SeaHaven.Services.csproj index 281c47c..993548e 100644 --- a/SeaHaven.Services/SeaHaven.Services.csproj +++ b/SeaHaven.Services/SeaHaven.Services.csproj @@ -16,4 +16,8 @@ + + + + diff --git a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs index e9831d7..1271cdb 100644 --- a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs +++ b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -17,6 +18,12 @@ public class SyncFieldMergePolicyTests return new ApplicationDbContext(options); } + private static WorkOrderFieldLockService CreateLocks(ApplicationDbContext context) + => new(new WorkOrderFieldLockDataService(context)); + + private static WorkOrderAuditService CreateAudit(ApplicationDbContext context, WorkOrderFieldLockService locks) + => new(new WorkOrderAuditDataService(context), locks); + [Fact] public async Task TryApply_WhenFieldLocked_RejectsAndAudits() { @@ -31,8 +38,8 @@ public class SyncFieldMergePolicyTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -61,8 +68,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -86,8 +93,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -113,8 +120,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -141,7 +148,7 @@ public class SyncFieldMergePolicyTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); + var locks = CreateLocks(context); var policy = new SyncFieldMergePolicy(locks); var canOverwrite = await policy.CanSyncOverwriteAsync(20, WorkOrderFieldNames.AssignTo); diff --git a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs index f7df5ed..7cc862c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs @@ -1,6 +1,6 @@ using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -17,6 +17,15 @@ public class WorkOrderAuditServiceTests return new ApplicationDbContext(options); } + private static (WorkOrderAuditService audit, ApplicationDbContext context) CreateAudit(ApplicationDbContext context) + { + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); + var auditData = new WorkOrderAuditDataService(context); + var audit = new WorkOrderAuditService(auditData, locks); + return (audit, context); + } + [Fact] public async Task LogStatusChanged_CreatesSingleAuditWithManualEventType() { @@ -24,8 +33,7 @@ public class WorkOrderAuditServiceTests context.workOrders.Add(new WorkOrder { Id = 1, Status = "Open" }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var (audit, _) = CreateAudit(context); await audit.LogStatusChangedAsync(1, "Open", "In Progress", "user-1"); @@ -44,8 +52,7 @@ public class WorkOrderAuditServiceTests context.workOrders.Add(new WorkOrder { Id = 2, Status = "Open" }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var (audit, _) = CreateAudit(context); await audit.LogStatusChangedAsync(2, "Open", "Scheduled", "user-1"); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index cfb7874..efad8f4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -17,11 +17,12 @@ public class WorkOrderBoardCancelServiceTests .Options; var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); - var cancel = new WorkOrderBoardCancelService(context, boardService, audit); - var update = new WorkOrderBoardUpdateService(context, boardData, audit); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit); + var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, cancel, update); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs index 7039963..16f8e9e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -22,9 +22,10 @@ public class WorkOrderBoardConcurrencyTests private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context) { var boardData = new WorkOrderBoardDataService(context); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); - return new WorkOrderBoardUpdateService(context, boardData, audit); + var mutationData = new WorkOrderBoardMutationDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + return new WorkOrderBoardUpdateService(boardData, mutationData, audit); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index 0e85674..bd098de 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -36,12 +36,13 @@ public class WorkOrderBoardCreateRelationalTests await context.SaveChangesAsync(); var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var service = new WorkOrderBoardCreateService( - context, boardData, + mutationData, boardService, audit, new WorkOrderBoardCreateValidation()); @@ -92,12 +93,13 @@ public class WorkOrderBoardCreateRelationalTests await context.SaveChangesAsync(); var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var service = new WorkOrderBoardCreateService( - context, boardData, + mutationData, boardService, audit, new WorkOrderBoardCreateValidation()); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index b3171f2..9ac5114 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -18,11 +18,12 @@ public class WorkOrderBoardCreateServiceTests .Options; var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var validator = new WorkOrderBoardCreateValidation(); - var service = new WorkOrderBoardCreateService(context, boardData, boardService, audit, validator); + var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs index 6abde14..cf89a8c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs @@ -20,12 +20,13 @@ public class WorkOrderBoardCreateSyncLockTests await using var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var createService = new WorkOrderBoardCreateService( - context, boardData, + mutationData, boardService, audit, new WorkOrderBoardCreateValidation()); @@ -64,12 +65,13 @@ public class WorkOrderBoardCreateSyncLockTests await using var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var createService = new WorkOrderBoardCreateService( - context, boardData, + mutationData, boardService, audit, new WorkOrderBoardCreateValidation()); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 20f27e3..883aeae 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -20,9 +20,10 @@ public class WorkOrderBoardUpdateServiceTests .Options; var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); - var service = new WorkOrderBoardUpdateService(context, boardData, audit); + var mutationData = new WorkOrderBoardMutationDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, service); } @@ -705,8 +706,8 @@ public class WorkOrderBoardUpdateServiceTests Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames); Assert.DoesNotContain("ApptTime", lockNames); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), locks); var policy = new SyncFieldMergePolicy(locks); var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1); var syncContext = new WorkOrderSyncContext diff --git a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs index b598a4d..d9a6a39 100644 --- a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; @@ -19,7 +20,8 @@ public class WorkOrderFieldLockServiceTests public async Task LockFieldAsync_DuplicateCalls_EnqueuesSingleLock() { await using var context = CreateContext(); - var locks = new WorkOrderFieldLockService(context); + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-1"); await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-2"); @@ -43,7 +45,8 @@ public class WorkOrderFieldLockServiceTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); await locks.LockFieldAsync(2, WorkOrderFieldNames.AssignTo, "user-2"); await context.SaveChangesAsync(); diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 85d0a98..1d7d2f6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -18,9 +18,10 @@ public class WorkOrderDocStatusPatchTests .Options; var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); - var service = new WorkOrderBoardUpdateService(context, boardData, audit); + var mutationData = new WorkOrderBoardMutationDataService(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, service); } @@ -167,10 +168,11 @@ public class WorkOrderCompletionServiceTests .Options; var context = new ApplicationDbContext(options); var templateData = new CompletionDocTemplateDataService(context); + var completionData = new WorkOrderCompletionDataService(context); var detailData = new WorkOrderDetailDataService(context); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); - var service = new WorkOrderCompletionService(context, templateData, detailData, audit); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderCompletionService(completionData, templateData, detailData, audit); return (context, service); } @@ -345,7 +347,8 @@ public class WorkOrderMediaServiceTests await context.SaveChangesAsync(); var detailData = new WorkOrderDetailDataService(context); - var service = new WorkOrderMediaService(context, detailData); + var mediaData = new WorkOrderMediaDataService(context); + var service = new WorkOrderMediaService(mediaData, detailData); var media = await service.GetMediaAsync(1); Assert.NotNull(media); @@ -376,7 +379,7 @@ public class WorkOrderMediaServiceTests }); await context.SaveChangesAsync(); - var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context)); + var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); var ex = await Assert.ThrowsAsync(() => service.DeleteMediaAsync(1, 10, "actor-1")); @@ -397,7 +400,7 @@ public class WorkOrderMediaServiceTests }); await context.SaveChangesAsync(); - var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context)); + var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1")); diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs index d3c0764..ce50f7e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -29,9 +29,9 @@ public class WorkOrderPhase7CoexistenceTests .Options; await using var context = new ApplicationDbContext(options); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); - var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); var payload = new WorkOrderIngestPayloadDto { @@ -65,13 +65,14 @@ public class WorkOrderPhase7CoexistenceTests await using var context = new ApplicationDbContext(options); var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); var boardService = new WorkOrderBoardService(boardData); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var createService = new WorkOrderBoardCreateService( - context, boardData, boardService, audit, new WorkOrderBoardCreateValidation()); + boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation()); var policy = new SyncFieldMergePolicy(fieldLocks); - var ingest = new WorkOrderIngestService(context, policy, fieldLocks, audit); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit); await createService.CreateAsync(new WorkOrderBoardCreateRequestDto { @@ -123,9 +124,9 @@ public class WorkOrderPhase7CoexistenceTests }); await context.SaveChangesAsync(); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); - var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); await ingest.UpsertBatchAsync(new[] { @@ -151,9 +152,9 @@ public class WorkOrderPhase7CoexistenceTests .Options; await using var context = new ApplicationDbContext(options); - var fieldLocks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, fieldLocks); - var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); var result = await ingest.UpsertBatchAsync(new[] { @@ -163,8 +164,8 @@ public class WorkOrderPhase7CoexistenceTests Assert.Equal(1, result.Created); Assert.Equal(1, result.Skipped); - Assert.Equal(1, result.Results.Count); - Assert.Equal("ext-ok", result.Results[0].ExternalWorkOrderId); + var createdResult = Assert.Single(result.Results); + Assert.Equal("ext-ok", createdResult.ExternalWorkOrderId); } [Fact] @@ -204,7 +205,7 @@ public class WorkOrderPhase7CoexistenceTests LegacySunsetDate = "2026-12-31" }); - var service = new WorkOrderOpsHealthService(context, runState, healthOptions); + var service = new WorkOrderOpsHealthService(new WorkOrderOpsHealthDataService(context), runState, healthOptions); var health = await service.GetHealthAsync(); Assert.Equal(1, health.FieldLockCount); diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs index 4aef509..e98ee23 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -27,7 +27,7 @@ public class WorkOrderWeekRolledTests private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context) { var dataService = new WorkOrderDomainJobDataService(context); - var audit = new WorkOrderAuditService(context, new WorkOrderFieldLockService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))); return new WorkOrderWeekRolledService( dataService, audit, From 833fb816eed88f3fdcab1e7e2b87d364442622c4 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 17:41:10 -0300 Subject: [PATCH 29/30] chore(governance): enforce backend architecture and quality gates (#31) * docs(governance): add canonical governance docs, unified quality-gate script, and CI parity - ARCHITECTURE_AND_CODE_QUALITY.md: canonical layering, EF allowlist, transaction/commit convention, cancellation, migrations, error disclosure, Big-O/perf, ADR exceptions (supersedes BACKEND_ARCHITECTURE.md) - QUALITY_GATES.md: gate inventory + pass/fail/skip semantics; local==CI - REVIEW_AND_PR_FRAMEWORK.md: exact-head review, board-backed regression inventory, security/perf evidence, ADR exceptions, no godfile theater - AGENTS.md: repo-specific delta + precedence pointers - scripts/governance-check.sh: unified G1 restore + G2 ArchitectureTests + G3 changed-file format (portable bash) - .github/workflows/architecture-quality.yml: call the same local script - ArchitectureTests.cs: add business-service interface-dependency invariant * fix(governance): make backend gate complete * fix(ci): enforce governance on every pull request --- .github/workflows/architecture-quality.yml | 34 +-- AGENTS.md | 70 +++++ ARCHITECTURE_AND_CODE_QUALITY.md | 273 ++++++++++++++++++ .../ArchitectureTests.cs | 21 ++ QUALITY_GATES.md | 125 ++++++++ REVIEW_AND_PR_FRAMEWORK.md | 132 +++++++++ scripts/governance-check.sh | 82 ++++++ 7 files changed, 709 insertions(+), 28 deletions(-) create mode 100644 AGENTS.md create mode 100644 ARCHITECTURE_AND_CODE_QUALITY.md create mode 100644 QUALITY_GATES.md create mode 100644 REVIEW_AND_PR_FRAMEWORK.md create mode 100755 scripts/governance-check.sh diff --git a/.github/workflows/architecture-quality.yml b/.github/workflows/architecture-quality.yml index ada851d..14ce54c 100644 --- a/.github/workflows/architecture-quality.yml +++ b/.github/workflows/architecture-quality.yml @@ -2,7 +2,6 @@ name: Architecture and changed-file quality on: pull_request: - branches: [main, dev] permissions: contents: read @@ -21,31 +20,10 @@ jobs: with: dotnet-version: "8.0.x" - - name: Restore - run: dotnet restore SeaHavenIndustries.sln - - - name: Verify architecture boundaries - run: >- - dotnet test - Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj - --no-restore - --filter FullyQualifiedName~ArchitectureTests - - - name: Verify formatting of changed C# files + # CI and local run the same complete repository gate. + - name: Repository quality gate shell: bash - run: | - mapfile -t files < <( - git diff --name-only --diff-filter=ACMR \ - "${{ github.event.pull_request.base.sha }}" \ - "${{ github.event.pull_request.head.sha }}" \ - -- '*.cs' - ) - - if (( ${#files[@]} == 0 )); then - exit 0 - fi - - dotnet format SeaHavenIndustries.sln \ - --no-restore \ - --verify-no-changes \ - --include "${files[@]}" + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + HEAD_REF: ${{ github.event.pull_request.head.sha }} + run: bash scripts/governance-check.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03e95fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# AGENTS.md — SeaHaven backend (repo-specific delta) + +This file is the **repo-specific delta** for the Seahaven backend. It layers on +top of the operator/workspace agent baseline and does not repeat it. This file +may add stricter backend rules but may never weaken the workspace baseline. + +## Canonical governance documents (precedence) + +1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical; + supersedes the older `BACKEND_ARCHITECTURE.md`, which is retained only as + historical reference). +2. `QUALITY_GATES.md` — *how* rules are enforced (commands + CI mapping). +3. `REVIEW_AND_PR_FRAMEWORK.md` — *who* reviews, in what order, with what + evidence. + +When these conflict with each other, the one that *owns* the topic wins +(meaning → architecture doc; execution → quality gates; process → review +framework). When unsure, ask; do not silently pick. + +## Architecture in one line + +```text +Controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext +``` + +- Controllers depend on `I{Feature}Service` only — never `DbContext`/EF/concrete + services/data services. +- Business services depend on feature-specific data/query/command interfaces — + never `DbContext`, never `IConfiguration`, never a generic repository. +- Data services own EF; EF/`DbContext` lives only on the documented + infrastructure allowlist (data services, `ApplicationDbContext`, migrations, + Identity/composition, transactions inside data services). +- One commit convention: `SaveChangesAsync(CancellationToken)` inside the owning + data service is the atomic boundary; explicit transactions stay inside data + services. +- Tenant scope is server-derived; authorization is enforced at service entry. +- No HTTP response leaks exception/stack/SQL/credential/crypto detail; secrets + are never logged. + +Full detail: `ARCHITECTURE_AND_CODE_QUALITY.md`. + +## Running the complete gate (local == CI) + +```bash +bash scripts/governance-check.sh +``` + +The command restores, verifies architecture and changed-file formatting, builds +Release, and runs the full test suite. CI (`architecture-quality` workflow) +calls the same script. See `QUALITY_GATES.md`. + +## Hard rules (deviation needs an ADR; no wildcard suppressions) + +- EF allowlist, dependency direction, no client exception disclosure, + server-derived tenant scope, one commit convention, cancellation forwarding + (verified, not just signed). +- Suppress a single diagnostic with a cited ADR only — never global sweeps. +- See `ARCHITECTURE_AND_CODE_QUALITY.md` §10 for the ADR exception process. + +## Do not touch without explicit instruction + +- Product behavior, database **migrations**, secrets, dependency versions, and + files unrelated to the current task. +- The `.ai-config-kit-sidecar-write-scope` marker file is a transient + authorization artifact — **never commit it**. + +## Reporting + +State every changed file with a one-line summary. Report each gate as +pass / fail / skipped / not-run with evidence; never infer a pass from silence. diff --git a/ARCHITECTURE_AND_CODE_QUALITY.md b/ARCHITECTURE_AND_CODE_QUALITY.md new file mode 100644 index 0000000..54b35a1 --- /dev/null +++ b/ARCHITECTURE_AND_CODE_QUALITY.md @@ -0,0 +1,273 @@ +# Architecture and Code Quality (canonical) + +> **Status: canonical.** This document owns the *meaning* of the backend's +> architecture and code-quality rules. On any conflict with the older +> `BACKEND_ARCHITECTURE.md`, **this document governs**; that file is retained +> only as historical reference pending removal. +> +> Companion documents: +> - `QUALITY_GATES.md` — *how* each rule is enforced (commands + CI mapping). +> - `REVIEW_AND_PR_FRAMEWORK.md` — *who* reviews, in what order, with what +> evidence, and how exceptions are recorded. +> - `AGENTS.md` — precedence and repo-specific agent delta. + +## 1. Layering model + +The backend is a feature-oriented service boundary over Entity Framework Core: + +```text +HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext +``` + +The interfaces are **architectural seams**, not a mandate to wrap every class or +every EF method. Add an interface when it separates HTTP, business, +persistence, or external I/O responsibility *and* enables behavior-focused +testing through the public contract. + +| Layer | May depend on | May not depend on | +|-------|---------------|-------------------| +| API controller | `I{Feature}Service`, `ILogger`, framework types | `DbContext`, concrete services, concrete data services, raw SQL, business workflow code | +| Business service (`SeaHaven.Services`) | `I{Feature}DataService`, `IOptions`, `ILogger`, pure ports (`IServicePorts`, `IDispatchEmailPort`, …) | `DbContext`, `IConfiguration`, concrete data services, a generic repository | +| Data service (`SeaHaven.DataServices`) | `ApplicationDbContext`, EF Core | Business decisions, HTTP concerns | +| Composition / infrastructure | `Program.cs`, `*Module.cs`, migrations, Identity | Being depended on by controllers or business services | + +### 1.1 Controllers + +- Parse HTTP input, authorize the caller, invoke a business-service interface, + and map the result to the established HTTP contract. +- Depend **only on `I{Feature}Service` interfaces** — never on `DbContext`, EF, + concrete service implementations, or concrete data-service implementations. +- Contain no persistence queries and no business workflows. +- Map known failures explicitly; let the centralized exception boundary handle + unexpected failures (see §5). + +### 1.2 Business services + +- Own validation, business decisions, orchestration, and **transaction intent** + (the *what* of semantic atomicity — see §4). +- Depend on **feature-specific data/query/command interfaces**, never on a + generic repository and never on `DbContext`. +- Use `IOptions` for typed configuration. Never inject `IConfiguration`. +- Keep feature responsibilities cohesive. Split a service when it has + independently changing business reasons — **not** because it crossed an + arbitrary line count (see §9, "no godfile threshold theater"). + +### 1.3 Data services + +- Own EF Core query shape, persistence, batching, and **transaction mechanics** + (the *how* of commits — see §4). +- Accept `CancellationToken` on new I/O methods and propagate it (see §6). +- Use `AsNoTracking()` for read-only queries. +- Project only required columns for list/read models; include full graphs only + when the caller needs them. +- Page unbounded collections at the database. +- Avoid query-in-loop / save-in-loop patterns; prefer set-based reads and + batched writes **while preserving the workflow's failure and transaction + semantics**. +- Do **not** introduce a generic repository over EF Core. Feature data services + expose intent-revealing operations. + +### 1.4 Infrastructure ownership and the EF allowlist + +EF Core / `DbContext` is owned by approved infrastructure only. The explicit +allowlist of places that may touch EF directly: + +- `SeaHaven.DataServices.Implementation.*` (feature data services). +- `Data.SeaHavenIndustries` — the `ApplicationDbContext`, entity configuration, + and **migrations** (see §7). +- `SeaHaven.DataServices.DependencyInjection.DataServicesModule` — context + registration and lifetime. +- Identity / authentication composition in `Program.cs` and the DI modules + (`AddDbContext`, `AddIdentity`, token/options wiring). +- Transaction mechanics inside data services when semantic atomicity requires + it (see §4). + +Anything outside this allowlist that references EF / `DbContext` is an +architecture violation. New allowlist entries require an ADR (see §10). + +## 2. Tenant scope and authorization + +- **Tenant scope is server-derived.** Filtering by tenant/customer/owner is + applied in the data access layer from authenticated principal claims, never + from client-supplied request bodies or query strings. +- **Authorization remains at service entry.** A business service's public method + is the trust boundary: it authorizes the caller and resolves the effective + tenant scope before doing work. Controllers express HTTP-level authorization + (`[Authorize]`, route/policy) and then delegate; they do not re-implement + service-level authorization. +- Data services receive an already-resolved, server-derived scope; they must not + invent authorization rules of their own. + +## 3. Dependency-direction enforcement (mechanical) + +`ArchitectureTests` (in `Api.SeaHavenIndustries.Tests`) enforces mechanical +dependency direction by reflection over constructor parameters: + +1. Controllers do not inject `DbContext`, concrete data services, or concrete + service implementations. +2. Business services do not inject `DbContext` or `IConfiguration`. +3. Business services depend on interfaces for their data/persistence + dependencies, not on concrete data-service or service implementations. +4. **Discovery must not silently pass on an empty set.** Each invariant asserts + the discovered type set is non-empty, so the gate **fails** if reflection + ever returns zero controllers/services (e.g., after a namespace move). A + passing run therefore means "real types were checked," not "nothing was + found." + +These tests cover *dependency direction*. Behavior lives in behavior tests +through public interfaces. Do **not** add tests merely to prove a linter or +analyzer itself works — test the product behavior the rule protects. Do not +claim full runtime properties from constructor reflection; it proves wiring +shape, not execution behavior. + +## 4. Transaction semantics and the one commit convention + +- **Services own semantic atomicity.** A business service decides which set of + operations constitutes a unit of work and expresses that intent. +- **Scoped infrastructure owns transaction mechanics.** The actual commit + boundary is `DbContext.SaveChangesAsync(...)` and lives **inside a data + service**. A service must not sprinkle independent `SaveChanges` calls across + data-service boundaries in a way that breaks the semantic unit. +- **The repo documents one commit convention:** a single + `SaveChangesAsync(CancellationToken)` inside the owning data service is the + atomic commit boundary. This is the current convention observed across the + data-service layer. +- When a unit of work genuinely requires multiple statements to be all-or- + nothing, use EF Core's transaction (`IDbContextTransaction`) **inside the data + service**. Explicit transactions are infrastructure (EF allowlist), owned by + data services, never by controllers or business services. +- Do not "optimize" atomicity away: collapsing several semantically independent + saves into one final save changes partial-success behavior and is a business + decision, not mechanical cleanup. + +## 5. Error disclosure and structured logging (security) + +- **No HTTP response may expose** arbitrary exception messages, stack traces, + or SQL/provider/path/credential/crypto detail. Known failures are mapped to + explicit public messages; unexpected failures are funneled through the + centralized boundary (`SanitizedErrors`), which returns a stable message plus + a correlation reference and never echoes internal exception text. +- **Correlated structured internal logs** record the original exception at + `Error` level with a correlation id, so incidents are traceable without + leaking detail to the client. +- **Secrets are never logged.** Configuration values, tokens, connection + strings, and credentials are treated as redacted; structured log templates + must not interpolate secret-bearing fields. +- The `SanitizedErrorsTests` guard the public-facing contract (stable message, + correlation id present, original exception text absent, original logged at + `Error`). + +## 6. Cancellation + +- New async I/O methods accept a `CancellationToken` where the public contract + permits and **propagate it** to EF Core / downstream I/O. +- **Do not falsely claim propagation.** A `CancellationToken` parameter that is + ignored, swallowed, or replaced with `default`/`CancellationToken.None` at the + call site is a defect, not a courtesy. The signature is a contract. +- Enforcement combines **behavior tests** (assert the token reaches the data + service / EF call for changed I/O paths) and **analyzers** where available. + Either is acceptable evidence; silence is not. For changed paths, review must + confirm forwarding, not merely the presence of the parameter. + +## 7. Database migrations + +Migration gates require **actual-provider** evidence, not reflection or +assumptions: + +- **Actual-provider discovery** — the migration provider is the configured + database provider (e.g., SQL Server / Npgsql), discovered from the runtime + configuration, not inferred from package references. +- **Snapshot consistency** — `ModelSnapshot` must be consistent with the model + after the migration set (`dotnet ef ... --output` / script diff with no + phantom operations). +- **Empty-db apply** — applying the full migration history to an empty database + must reproduce the current model with no errors. +- **Supported-prior upgrade** — upgrading from each still-supported prior state + applies cleanly. +- **No untested rollback claims.** Do not assert "down/rollback is supported" + unless a downgrade is actually exercised against the real provider; otherwise + state rollback as **not guaranteed**. EF "EnsureDeleted"/re-create is not a + migration rollback. + +## 8. Query performance and complexity + +For each changed hot path, document and test: + +- input-size variables; +- CPU and memory complexity (Big-O) for any in-memory reconstruction; +- database round trips; +- whether filtering, ordering, and paging execute **in SQL** vs. in memory; +- whether tracking is necessary; +- whether repeated work can be hoisted out of loops; +- the partial-failure and transaction behavior. + +Distinguish two classes of finding: + +- **Analyzable anti-patterns** — query-in-loop / save-in-loop, unbounded load + followed by in-memory `Where`, repeated `First`/`Single` scans over a result + (prefer dictionary/hash-set reconstruction, normally `O(n)` vs `O(n²)`). These + can be flagged by inspection. +- **SQL-backed evidence** — actual paging/filtering/sort location, query count, + and plan shape must be backed by provider query logs, `ToQueryString()`, + store-generated SQL, or a plan/count measurement. Do **not** assert "executes + in SQL" or "single round trip" from LINQ shape alone. + +Do not optimize by weakening correctness: batching all writes into one final +save can change partial-success behavior; adding a transaction can change +locking/retry semantics. Both are business decisions. + +## 9. Size, complexity, and "no godfile theater" + +- We do **not** enforce arbitrary line-count or file-size thresholds as quality + gates. A number is not an architecture. +- A file is too large when it has **independently changing responsibilities** + (low cohesion) or **untestable coupling**, not when it crosses a count. + Split on seams, not on a counter; describe the seam in the PR. +- Gates that operate on size must name the *consequence* (e.g., "two unrelated + features in one service") and be backed by review, not by a magic threshold. + +## 10. Exceptions and ADRs + +Some rules here are hard (EF allowlist, no client exception leakage, server- +derived tenant scope). A deviation from a hard rule requires an **Architecture +Decision Record** under `docs/adr/` (create the path if absent): + +- A short `NNNN-title.md` stating context, decision, consequences, and the + specific rule being excepted. +- The excepted rule and an expiry/review date. +- Reference from the PR that relies on it. + +Without an ADR, the rule stands as written. Explicit, documented exceptions +only — **no wildcard suppressions** (no global `[SuppressMessage]`, no +`.editorconfig` `dotnet_diagnostic.*.severity = none` sweeps, no +`#pragma` swaths). A suppression must name the single diagnostic and cite the +ADR/justification. + +## 11. Configuration + +Configuration is bound once during composition in +`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services +receive typed options (`FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, +`VendorPortalOptions`, …). Defaults must preserve current behavior. Startup +validation is introduced only when every deployed environment is known to +provide the required value. + +## 12. Review checklist (quick reference) + +- Controller depends only on `I{Feature}Service` abstractions. +- Business logic is in a cohesive feature service. +- EF operations are behind a feature data-service abstraction. +- No raw `IConfiguration` in a business service. +- No unbounded load + in-memory filtering. +- No query/save loop where a set-based operation preserves semantics. +- Read-only EF queries use no tracking. +- New async I/O accepts and **propagates** cancellation where the contract + permits (verified, not just signed). +- Errors do not leak internal exception details; secrets are not logged. +- Tenant scope is server-derived; authorization enforced at service entry. +- Commit convention respected (single `SaveChangesAsync` in the owning data + service; transactions only in data services when required). +- Tests cover behavior, authorization, data contracts, ordering, duplicates, + missing records, and relevant failure boundaries. +- Changed behavior checked against the authoritative ticket board (see + `REVIEW_AND_PR_FRAMEWORK.md`). Missing board access blocks readiness/merge. diff --git a/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs b/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs index b636e67..8624cca 100644 --- a/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs +++ b/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs @@ -73,4 +73,25 @@ public class ArchitectureTests violations.Should().BeEmpty( "business services must depend on data-service interfaces and typed options, never on DbContext or IConfiguration"); } + + [Fact] + public void BusinessServices_DependOnInterfaces_NotConcreteServiceOrDataServiceImplementations() + { + // Guards the feature-specific interface boundary: a business service must + // depend on I{Feature}DataService / I{Feature}Service abstractions, never on + // concrete service or data-service implementations (no generic-repository + // theater and no coupling to persistence implementations). + BusinessServiceImplementations.Should().NotBeEmpty( + "business services must be discovered for this invariant to be meaningful"); + + var violations = ParametersOf(BusinessServiceImplementations) + .Where(p => IsConcreteServiceImplementation(p.ParameterType) + || IsConcreteDataService(p.ParameterType)) + .Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})") + .ToList(); + + violations.Should().BeEmpty( + "business services must depend on interfaces (I{Feature}Service / I{Feature}DataService), " + + "never on concrete service or data-service implementations"); + } } diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md new file mode 100644 index 0000000..e7b5c84 --- /dev/null +++ b/QUALITY_GATES.md @@ -0,0 +1,125 @@ +# Quality Gates (canonical) + +> **Status: canonical.** This document owns *how* the architecture and +> code-quality rules are enforced: the gate list, the local command, the CI +> mapping, and the pass/fail/skip reporting semantics. The *meaning* of each +> rule lives in `ARCHITECTURE_AND_CODE_QUALITY.md`. +> +> **CI and local run the same check.** Every gate below is executed by +> `scripts/governance-check.sh`. The `architecture-quality` workflow calls that +> script so a green run means the same thing locally and in CI. Do not add a +> check to CI that is not also runnable locally through the script (and vice +> versa). + +## Gate inventory + +| # | Gate | Protects (architecture §) | Local command | CI step | +|---|------|---------------------------|---------------|---------| +| G1 | Restore | build integrity | `dotnet restore SeaHavenIndustries.sln` | `architecture-quality` → `governance-check.sh` | +| G2 | Architecture boundary tests | §1, §3 (dependency direction) | `dotnet test Api.SeaHavenIndustries.Tests ... --filter FullyQualifiedName~ArchitectureTests --no-restore` | same script | +| G3 | Changed-file formatting | §1 (conventions) | `dotnet format SeaHavenIndustries.sln --no-restore --verify-no-changes --include ` | same script | +| G4 | Release build | compile correctness | `dotnet build SeaHavenIndustries.sln -c Release --no-restore` | `architecture-quality` and `ci` | +| G5 | Full test suite | behavior | `dotnet test SeaHavenIndustries.sln -c Release --no-build` | `architecture-quality` and `ci` | +| G6 | Migration gates | §7 | see §"Migration gates" below | on-demand / release | +| G7 | Cancellation forwarding | §6 | behavior tests on changed I/O paths + analyzer | review-enforced on changed paths | +| G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci` | +| G9 | Board-backed regression | review framework | `REVIEW_AND_PR_FRAMEWORK.md` inventory | review-enforced | + +## How to run locally + +```bash +# Complete local repository gate (mirrors the architecture-quality workflow): +bash scripts/governance-check.sh + +# By default it compares against the default branch for changed-file formatting. +# Override the comparison point: +BASE_REF=origin/dev bash scripts/governance-check.sh +BASE_REF=main bash scripts/governance-check.sh +``` + +The script: +1. `dotnet restore` (G1) +2. runs the `ArchitectureTests` filter with `--no-restore` (G2) +3. computes changed `.cs` files vs `BASE_REF` (default `origin/dev`) and runs + `dotnet format --verify-no-changes --include ...` (G3). When there are no + changed C# files it skips G3 with an explicit "skipped: no changed C#" line. +4. builds the complete solution in Release with no restore (G4). +5. runs the complete solution test suite in Release with no rebuild (G5). + +## Migration gates (G6) + +Provider-real, not reflection-derived. Run against the configured provider: + +- **Actual-provider discovery** — confirm the runtime provider matches the + configured connection (e.g., SQL Server / Npgsql) from configuration, not + from package guesses. +- **Snapshot consistency** — `dotnet ef migrations script --idempotent` (or a + model diff) shows no phantom operations; `ModelSnapshot` matches the model. +- **Empty-db apply** — `dotnet ef database update` against a fresh empty + database applies the full history cleanly. +- **Supported-prior upgrade** — upgrade from each still-supported prior + migration applies cleanly. +- **Rollback** — report **not guaranteed** unless a real downgrade was + exercised against the provider. Never claim "EF can roll it back" untested. + +Migration gates are run on-demand and gated for releases; they are not in the +per-PR fast path because they require a database. A PR that adds/changes a +migration must document the G6 evidence in the PR description. + +## Cancellation forwarding (G7) + +For changed async I/O paths, the requirement (§6) is **verified forwarding**, +not a signature checkbox. Acceptable evidence, any one of: + +- a behavior test asserting the `CancellationToken` reaches the data service / + EF call; or +- an analyzer diagnostic confirming propagation on the changed method. + +A parameter that exists but is swallowed/`default`-ed is a **fail**. If an +analyzer is unavailable for a path, a behavior test is required; silence is not +acceptable. Review enforces this on changed paths. + +## Architecture discovery must fail on zero (G2 invariants) + +Each reflection invariant in `ArchitectureTests` asserts the discovered set is +non-empty. A future namespace move that makes reflection return zero types will +**fail the gate** rather than silently pass. The current invariants: + +1. Controllers inject neither `DbContext`, concrete data services, nor concrete + service implementations. +2. Business services inject neither `DbContext` nor `IConfiguration`. +3. Business services depend on interfaces (not concrete service/data-service + implementations) for their data and persistence dependencies. + +## What this does not claim + +- Reflection over constructors proves **wiring shape**, not runtime behavior. + Runtime/semantic properties (cancellation actually cancelling, a query + executing in SQL, a transaction actually committing atomically) are proven by + behavior tests or provider evidence, never by reflection. +- A passing script proves G1–G5 ran in sequence. It does not prove the + provider-backed or review-owned gates G6–G9. + +## Pass / fail / skip / not-run reporting + +Every gate result is reported as exactly one of: + +- **pass** — ran and succeeded, with evidence. +- **fail** — ran and failed; cite the failing test/command and output. +- **skipped** — intentionally not run for this change (e.g., no changed C# for + G3); name the gate and the reason. +- **not-run** — could not run (environment/dependency missing); name the + blocker. + +A PR is not "green" if any applicable gate is fail, skipped-without-justification, +or not-run. Never infer a pass from silence. + +## Adding or changing a gate + +- Any new executable check goes into `scripts/governance-check.sh` **and** the + `architecture-quality` workflow together (or into the full CI for G4/G5-type + checks). One source of truth for local and CI. +- A new rule documents its meaning in `ARCHITECTURE_AND_CODE_QUALITY.md` and its + command/mapping here. +- Explicit, documented exceptions only — no wildcard suppressions (see + `ARCHITECTURE_AND_CODE_QUALITY.md` §10). diff --git a/REVIEW_AND_PR_FRAMEWORK.md b/REVIEW_AND_PR_FRAMEWORK.md new file mode 100644 index 0000000..04b70dc --- /dev/null +++ b/REVIEW_AND_PR_FRAMEWORK.md @@ -0,0 +1,132 @@ +# Review and PR Framework (canonical) + +> **Status: canonical.** This document owns *who* reviews, in what order, with +> what evidence, and how exceptions are recorded. The rules under review live in +> `ARCHITECTURE_AND_CODE_QUALITY.md`; the gates live in `QUALITY_GATES.md`. + +## 1. Exact-head review + +Reviews are performed against the **exact HEAD** the PR will merge, not a stale +snapshot: + +- Fetch and review the precise merge commit / head SHA. If new commits land + after review, the review covers only the previously seen diff until re-run. +- Comments cite `file:line` against current HEAD so they are navigable and + reproducible. +- A "looks good from what I remember" approval is invalid; re-diff before + approving. + +Review order is **business-rule-first, high-signal, low-false-positive**: + +1. Correctness of the changed business behavior vs. the acceptance criteria. +2. Regression risk against board-backed protected behavior (§2). +3. Security / data / authorization (§3). +4. Architecture / dependency direction (the `ArchitectureTests` gate already + covers mechanical direction; review covers *semantic* layering). +5. Performance / complexity evidence (§4). +6. Tests: do they assert behavior through public contracts? +7. Nitpicks / style last, and only if not already caught by `dotnet format`. + +## 2. Board-backed regression inventory + +A task is incomplete if it implements the current change while regressing +behavior that was already working. Before claiming review/merge readiness: + +- Pull the **Seahaven Jira SH board inventory** — all + visible tickets, not only the current one: key, title, type, status, + component, acceptance criteria, linked PR/release, and QA evidence. +- Check the change against that inventory. A plausible regression against + protected (Done / released / QA-approved) behavior is a **Blocker** until + disproven with repo evidence and targeted validation. +- **Missing board access, incomplete inventory, or missing PR-to-ticket + traceability is Blocked / NOT READY** — never a pass. Say so explicitly. + +If Jira cannot be reached, report the gate as **not-run / blocked**, not green. +The current workflow has no `Ready for QA` transition; merged-but-unverified +work remains in the documented pre-QA status and must not be mislabeled Done. + +## 3. Security, data, and authorization evidence + +For changes touching auth, data, crypto, external input, or configuration, +review requires evidence (not assertion) for: + +- **No exception disclosure** — changed endpoints do not leak exception + messages, stack traces, or SQL/provider/path/credential/crypto detail to the + client. `SanitizedErrorsTests` covers the shared boundary; changed handlers + must route through it. +- **Secret handling** — no secret-bearing field is logged or serialized into a + response. Structured log templates must not interpolate secrets. +- **Tenant scope** — scope is server-derived and authorization is enforced at + service entry (§2 of the architecture doc). +- **Authorization** — the service entry trust boundary is intact; controllers + express HTTP policy and delegate. + +Cite the test, log redaction, or code path for each. "We don't think it leaks" +without evidence is a fail. + +## 4. Performance and Big-O evidence + +For each changed hot path, the PR provides: + +- **Complexity** — CPU and memory Big-O for any in-memory step (e.g., dictionary + reconstruction is `O(n)`; repeated `First`/`Single` scans are `O(n²)`). +- **SQL-backed evidence** — claims that filtering/ordering/paging execute in + SQL, or that there is a single round trip, are backed by provider query logs, + `ToQueryString()`, store SQL, or a measured count/plan — **not** inferred from + LINQ shape. +- **Round trips / loops** — no query-in-loop / save-in-loop where a set-based + operation preserves semantics; document when a loop is intentional and why. +- **Partial-failure / transaction behavior** — preserved or explicitly changed + by business decision (see architecture §4/§8). + +## 5. Tests + +- Tests assert **behavior through public interfaces**, not linter/analyzer + self-proofs. +- Cover: the happy path, authorization, data contracts, ordering, duplicates, + missing records, and relevant failure boundaries. +- New I/O paths include cancellation-forwarding evidence (architecture §6). +- A test that only proves "the rule exists" is not a behavior test; rewrite it + to exercise the product behavior the rule protects. + +## 6. Size and "no godfile theater" + +- Do not request splits based on line/file counts. A number is not feedback. +- If a unit is too large, name the **concrete second responsibility** or the + **untestable coupling** and propose the seam. Otherwise size is not a blocker. +- Do not introduce file/line thresholds into gates (architecture §9). + +## 7. Exceptions (ADR) + +A deviation from a hard rule requires an **Architecture Decision Record** under +`docs/adr/NNNN-title.md` (create the path if absent), referenced from the PR: + +- context, decision, consequences; +- the specific rule being excepted; +- expiry/review date. + +Suppressions are single-diagnostic and cite the ADR — **no wildcard +suppressions** (no global `[SuppressMessage]`, no `.editorconfig` severity +sweeps, no `#pragma` swaths). See architecture §10. + +## 8. PR description contract (minimal) + +- **Summary** — what changed and why, in plain language. +- **Changes and value** — grouped by area, each with the value it delivers. +- **Ticket** — the board key(s) when one applies. A missing ticket link is not + itself a blocker; missing board regression access or contradictory acceptance + criteria is. +- Link G6 migration evidence if the PR adds/changes a migration. +- Link any ADR relied upon. + +Avoid boilerplate: no deployment notes, no validation transcripts, no +"residual-risk" theatre, no AI signatures. The validation story lives in the +check run results and the close-out, not in the PR body. + +## 9. Merge readiness + +Merge requires: exact-head review done; board-backed regression check pass (or +documented blocked); security/perf evidence present where applicable; CI green +(G1–G5); migration evidence (G6) if touched; no open hard-rule exceptions +without an ADR. Report each as pass / fail / skipped / not-run — never infer a +pass. diff --git a/scripts/governance-check.sh b/scripts/governance-check.sh new file mode 100755 index 0000000..4175287 --- /dev/null +++ b/scripts/governance-check.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# governance-check.sh — local/CI parity governance gate for the Seahaven backend. +# +# Runs G1 restore, G2 ArchitectureTests, G3 changed-file formatting, G4 Release +# build, and G5 full tests exactly as the `architecture-quality` workflow does. +# A green run here means the same thing locally and in that CI workflow. +# +# Usage: +# bash scripts/governance-check.sh +# BASE_REF=origin/dev bash scripts/governance-check.sh +# BASE_REF= HEAD_REF= bash scripts/governance-check.sh +set -euo pipefail + +SOLUTION="SeaHavenIndustries.sln" +ARCH_TEST_PROJECT="Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj" + +log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } +ok() { printf '\033[32mPASS\033[0m %s\n' "$1"; } +bad() { printf '\033[31mFAIL\033[0m %s\n' "$1"; } + +if [[ -n "${DOTNET_BIN:-}" ]]; then + DOTNET="$DOTNET_BIN" +elif command -v dotnet >/dev/null 2>&1; then + DOTNET="$(command -v dotnet)" +elif [[ -x "$HOME/.dotnet/dotnet" ]]; then + DOTNET="$HOME/.dotnet/dotnet" +else + bad "dotnet is unavailable; set DOTNET_BIN or install the repository SDK." + exit 1 +fi + +# Comparison point for changed-file formatting. Default to the dev integration +# branch locally; CI overrides BASE_REF/HEAD_REF with the PR base/head SHAs. +BASE_REF="${BASE_REF:-origin/dev}" +HEAD_REF="${HEAD_REF:-HEAD}" + +# Resolve the base ref before using it for a diff. +if ! git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then + bad "G3: BASE_REF '${BASE_REF}' does not resolve to a commit (run: git fetch origin)." + exit 1 +fi + +log "G1: restore" +"$DOTNET" restore "$SOLUTION" +ok "G1: restore" + +log "G2: architecture boundary tests (dependency direction)" +"$DOTNET" test "$ARCH_TEST_PROJECT" \ + --no-restore \ + --filter "FullyQualifiedName~ArchitectureTests" \ + --nologo +ok "G2: ArchitectureTests" + +log "G3: changed-file formatting (${BASE_REF}..${HEAD_REF})" +changed_cs=() +while IFS= read -r f; do + changed_cs+=("$f") +done < <( + git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${HEAD_REF}" -- '*.cs' +) + +if (( ${#changed_cs[@]} == 0 )); then + printf '\033[33mSKIP\033[0m G3: no changed C# files between %s..%s\n' "${BASE_REF}" "${HEAD_REF}" +else + printf ' checking %d changed C# file(s)\n' "${#changed_cs[@]}" + "$DOTNET" format "$SOLUTION" \ + --no-restore \ + --verify-no-changes \ + --include "${changed_cs[@]}" + ok "G3: changed-file formatting" +fi + +log "G4: Release build" +"$DOTNET" build "$SOLUTION" -c Release --no-restore --nologo +ok "G4: Release build" + +log "G5: full test suite" +"$DOTNET" test "$SOLUTION" -c Release --no-build --nologo +ok "G5: full test suite" + +log "governance-check: all required repository gates passed" From 620a36af5475deb5f5d8ad6815443caa8879b9eb Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 24 Jul 2026 18:12:03 -0300 Subject: [PATCH 30/30] feat(work-orders): enrich board search overdue filters and 0-based paging (#23) * feat(work-orders): enrich board search overdue filters and 0-based paging * fix(work-orders): align stacked services with CI build * fix(tests): pass userDataService in comment service unit test * fix(work-orders): use dedicated overdue query flag Stop treating WorkOrderType.Other as an overdue sentinel. Board and advanced search now accept overdue=true while types=Other filters real Other rows; combining both uses OR. * test(work-orders): cover overdue date/status boundary and Other type-filter Lock the PR #23 overdue regression boundary through the public advanced search service. Prove overdue filtering is driven by past-due date plus non-terminal status, not by the WorkOrderType.Other sentinel: - Other + future/not-completed excluded from overdue - past-due + Scheduled included; past-due + Completed/Canceled excluded - types=[PM, Other] keeps real Other rows and does not pull past-due rows - assert 0-based paging (Page=0) is preserved alongside overdue/type filters --------- Co-authored-by: Arthur Bassi Co-authored-by: Alexandre Brandizzi --- .../WorkOrderControllerSearchTests.cs | 14 + .../Controllers/WorkOrderBoardController.cs | 2 + .../Auth/ApplicationDbContext.cs | 6 +- .../Helpers/WorkOrderBoardQueryFilters.cs | 50 ++- .../Helpers/WorkOrderBoardSearchFilter.cs | 88 ++++- .../WorkOrderAdvancedSearchDataService.cs | 4 +- .../WorkOrderBoardDataService.cs | 2 +- .../WorkOrderAdvancedSearchModels.cs | 1 + .../Interfaces/WorkOrderBoardModels.cs | 1 + SeaHaven.Services.Tests/PagedResultTests.cs | 23 +- SeaHaven.Services/DTOs/ContactDTOs.cs | 15 +- SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 4 +- .../Helpers/WorkOrderBoardSortFields.cs | 29 ++ .../WorkOrderSearchDateRangeResolver.cs | 6 +- .../WorkOrderAdvancedSearchService.cs | 12 +- .../Implementation/WorkOrderBoardService.cs | 1 + .../Implementation/WorkOrderCommentService.cs | 63 ++- .../Interfaces/IWorkOrderCommentService.cs | 8 +- .../WorkOrderBoardSearchTests.cs | 372 +++++++++++++++++- .../WorkOrderBoardServiceTests.cs | 3 + .../WorkOrderPhase6Tests.cs | 3 +- 22 files changed, 675 insertions(+), 34 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index 10ac872..c2a11aa 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; +using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; @@ -60,4 +61,17 @@ public class WorkOrderControllerSearchTests body.Status.Should().Be("Error"); body.Message.Should().Contain("dateFrom"); } + + [Fact] + public async Task GetBoard_WhenWeekEndBeforeWeekStart_ReturnsBadRequest() + { + var controller = NewController(Mock.Of()); + + var result = await controller.GetBoard( + new DateOnly(2026, 6, 22), + new DateOnly(2026, 6, 15)); + + var badRequest = result.Should().BeOfType().Subject; + badRequest.Value.Should().BeOfType().Which.Should().Contain("weekEnd"); + } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs index 204b742..85f6698 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -53,6 +53,7 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] List? dispatchers = null, [FromQuery] bool myWorkOrders = false, [FromQuery] List? types = null, + [FromQuery] bool overdue = false, [FromQuery] string? search = null) { var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4); @@ -70,6 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers Dispatchers = dispatchers, MyWorkOrders = myWorkOrders, Types = types, + Overdue = overdue, Search = search }; diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 48c34fe..2e214bb 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -206,7 +206,7 @@ namespace Data.SeaHavenIndustries return bytes; } - + } public class ApplicationUser : IdentityUser { @@ -222,8 +222,8 @@ namespace Data.SeaHavenIndustries public string? Initials { get; set; } public string? Color { get; set; } public int? Type { get; set; } // 1 for users 0 for admin - public ICollection