This commit is contained in:
Arthur Bassi 2026-07-17 14:28:40 +00:00 • committed by GitHub
commit cb3b781d59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
192 changed files with 26564 additions and 257 deletions

View file

@ -1,11 +1,11 @@
# Sea Haven Industries — shoc-backend required configuration
# Sea Haven Industries ÔÇö shoc-backend required configuration
#
# This file documents the secrets that used to be hardcoded in appsettings*.json
# and source files. Copy the values into one of the supported configuration sources;
# do NOT commit real values.
#
# .NET resolves configuration in this order (later wins):
# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only)
# 1. appsettings.json / appsettings.{Environment}.json (committed ÔÇö placeholders only)
# 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value"
# 3. Environment variables (use "__" as the section separator)
#
@ -32,3 +32,16 @@ GoogleMaps__ApiKey=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_REGION=us-east-2
# --- Work Order ingest (Lambda cutover) ---
# Leave Enabled=false until ApiKey is a real secret (min 32 chars). Do not use appsettings placeholders.
WorkOrderIngest__Enabled=false
WorkOrderIngest__ApiKey=<shared-secret-for-X-Ingest-Key-min-32-chars>
# --- Dynamo sync bridge (keep false after Lambda cutover; avoid dual-run with ingest) ---
Sync__Enabled=true
# --- Legacy endpoint deprecation headers ---
LegacyEndpoints__DeprecationEnabled=false
LegacyEndpoints__SunsetDate=2026-12-31

View file

@ -0,0 +1,109 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationControllerSitesTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationController NewController(ApplicationDbContext ctx)
{
var dataService = new LocationDataService(ctx);
var service = new LocationService(
dataService,
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
var controller = new LocationController(service, dataService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
return controller;
}
private static async Task SeedSitesAsync(ApplicationDbContext ctx)
{
ctx.Locations.AddRange(
new Locations { Id = 1, Name = "STL8", City = "St. Louis", State = "MO" },
new Locations { Id = 2, Name = "BK5", City = "Dallas", State = "TX" },
new Locations { Id = 3, Name = "IND9", City = "Indianapolis", State = "IN" },
new Locations { Id = 4, Name = "DFW8", City = "Dallas", State = "TX" });
await ctx.SaveChangesAsync();
}
[Fact]
public async Task GetSites_ReturnsIdCodeCityState_OrderedByCode()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().HaveCount(4);
data.Select(s => s.Code).Should().ContainInOrder("BK5", "DFW8", "IND9", "STL8");
data[0].Should().BeEquivalentTo(new SiteOptionDTO
{
Id = 2,
Code = "BK5",
City = "Dallas",
State = "TX"
});
}
[Fact]
public async Task GetSites_FiltersByCode()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites("bk5");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().ContainSingle();
data[0].Code.Should().Be("BK5");
}
[Fact]
public async Task GetSites_FiltersByCity()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites("Dallas");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().HaveCount(2);
data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8");
}
}

View file

@ -0,0 +1,163 @@
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.Implementation;
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<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static UserManager<ApplicationUser> MockUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
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<IWorkOrderService>(),
Mock.Of<IWorkOrderDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IQuotesDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDataService>(),
Mock.Of<ITaskListTemplateDataService>(),
ctx,
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IHttpContextAccessor>(),
null!,
null!,
Mock.Of<IConfiguration>(),
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
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<IWorkOrderAdvancedSearchDataService>());
var controller = NewController(ctx, advancedSearch);
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom
});
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("dateFrom");
}
[Fact]
public async Task GetBoard_WhenWeekEndBeforeWeekStart_ReturnsBadRequest()
{
using var ctx = NewContext();
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(ctx));
var controller = NewController(ctx, Mock.Of<IWorkOrderAdvancedSearchService>());
// Replace board service - controller uses injected mock. Need different approach.
// Use real board service via manual controller setup
var advancedSearch = Mock.Of<IWorkOrderAdvancedSearchService>();
var controllerWithBoard = new WorkOrderController(
MockUserManager(),
Mock.Of<IWorkOrderService>(),
Mock.Of<IWorkOrderDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IQuotesDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDataService>(),
Mock.Of<ITaskListTemplateDataService>(),
ctx,
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IHttpContextAccessor>(),
null!,
null!,
Mock.Of<IConfiguration>(),
boardService,
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
controllerWithBoard.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"))
}
};
var result = await controllerWithBoard.GetBoard(
new DateOnly(2026, 6, 22),
new DateOnly(2026, 6, 15));
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("weekEnd");
}
}

View file

@ -26,6 +26,13 @@ namespace Api.SeaHavenIndustries.Controllers
_locationDataService = locationDataService;
}
[HttpGet("sites")]
public async Task<IActionResult> GetSites(string? search = null)
{
var data = await _locationService.GetSiteOptionsAsync(search);
return Ok(data);
}
[HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10)
{

View file

@ -1,32 +1,49 @@
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;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/[controller]")]
public class SyncController : Controller
{
private readonly ApplicationDbContext _db;
private readonly AmazonDynamoDBClient _dynamo;
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)
public SyncController(ApplicationDbContext db, IOptions<SyncOptions> syncOptions)
{
_db = db;
_syncOptions = syncOptions.Value;
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
}
private IActionResult? SyncDisabledResult()
{
if (_syncOptions.Enabled)
return null;
return StatusCode(StatusCodes.Status503ServiceUnavailable, new
{
message = "DynamoDB sync bridge is disabled. Use POST /api/workorders/ingest."
});
}
[HttpPost("WorkOrders")]
public async Task<IActionResult> SyncWorkOrders()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var synced = 0;
var created = 0;
var updated = 0;
@ -62,12 +79,12 @@ namespace Api.SeaHavenIndustries.Controllers
var existing = await _db.workOrders
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId);
var locationId = await ResolveLocationId(
var (locationId, locationWasCreated) = await ResolveLocationId(
GetString(item, "site_code"),
GetString(item, "building"),
GetString(item, "address"));
if (locationId.HasValue && !locationsCreated.Equals(0))
if (locationWasCreated)
locationsCreated++;
if (existing == null)
@ -138,6 +155,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("Comments")]
public async Task<IActionResult> SyncComments()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var synced = 0;
var created = 0;
var skipped = 0;
@ -212,6 +232,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillInternalWONumbers")]
public async Task<IActionResult> BackfillInternalWONumbers()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var wosMissing = await _db.workOrders
.Where(w => w.InternalWONumber == null || w.InternalWONumber == "")
.OrderBy(w => w.Id)
@ -243,6 +266,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillDispatchNumbers")]
public async Task<IActionResult> BackfillDispatchNumbers()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var missing = await _db.Dispatches
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
.OrderBy(d => d.Id)
@ -270,6 +296,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("VendorReplies")]
public async Task<IActionResult> SyncVendorReplies()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var synced = 0;
var created = 0;
var skipped = 0;
@ -341,6 +370,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillCommentTypes")]
public async Task<IActionResult> BackfillCommentTypes()
{
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 });
@ -349,6 +381,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("All")]
public async Task<IActionResult> SyncAll()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var woResult = await SyncWorkOrders() as OkObjectResult;
var commentResult = await SyncComments() as OkObjectResult;
@ -359,17 +394,17 @@ namespace Api.SeaHavenIndustries.Controllers
});
}
private async Task<int?> ResolveLocationId(string? siteCode, string? building, string? address)
private async Task<(int? LocationId, bool Created)> ResolveLocationId(string? siteCode, string? building, string? address)
{
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
return null;
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;
return (existing.Id, false);
var location = new Locations
{
@ -382,7 +417,7 @@ namespace Api.SeaHavenIndustries.Controllers
_db.Locations.Add(location);
await _db.SaveChangesAsync();
return location.Id;
return (location.Id, true);
}
private static string? GetString(Dictionary<string, AttributeValue> item, string key)

View file

@ -174,6 +174,7 @@ namespace Api.SeaHavenIndustries.Controllers
{
v.Id,
v.CompanyName,
v.ContactName,
v.TradeSpecialties,
v.Address,
v.City,
@ -191,6 +192,7 @@ namespace Api.SeaHavenIndustries.Controllers
{
v.Id,
v.CompanyName,
v.ContactName,
v.TradeSpecialties,
address = string.Join(", ", addr),
distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null

View file

@ -17,11 +17,12 @@ using System.Security.Claims;
using static System.Runtime.InteropServices.JavaScript.JSType;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.DataServices.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
// [Authorize]
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
@ -42,6 +43,15 @@ namespace Api.SeaHavenIndustries.Controllers
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;
public WorkOrderController(
UserManager<ApplicationUser> userManager,
@ -58,7 +68,16 @@ namespace Api.SeaHavenIndustries.Controllers
IHttpContextAccessor httpContext,
Helper.SendMessage sendMessage,
Helper.VendorPortalTokenService vendorTokens,
IConfiguration config)
IConfiguration config,
IWorkOrderBoardService workOrderBoardService,
IWorkOrderBoardUpdateService boardUpdateService,
IWorkOrderBoardCreateService boardCreateService,
IWorkOrderBoardCancelService boardCancelService,
IWorkOrderAdvancedSearchService advancedSearchService,
IWorkOrderDetailService workOrderDetailService,
IWorkOrderCommentService workOrderCommentService,
IWorkOrderCompletionService workOrderCompletionService,
IWorkOrderMediaService workOrderMediaService)
{
_userManager = userManager;
_workOrderService = workOrderService;
@ -75,7 +94,390 @@ namespace Api.SeaHavenIndustries.Controllers
_sendMessage = sendMessage;
_vendorTokens = vendorTokens;
_config = config;
_workOrderBoardService = workOrderBoardService;
_boardUpdateService = boardUpdateService;
_boardCreateService = boardCreateService;
_boardCancelService = boardCancelService;
_advancedSearchService = advancedSearchService;
_workOrderDetailService = workOrderDetailService;
_workOrderCommentService = workOrderCommentService;
_workOrderCompletionService = workOrderCompletionService;
_workOrderMediaService = workOrderMediaService;
}
[HttpGet("board")]
public async Task<IActionResult> GetBoard(
[FromQuery] DateOnly weekStart,
[FromQuery] DateOnly? weekEnd = null,
[FromQuery] List<string>? dispatchers = null,
[FromQuery] bool myWorkOrders = false,
[FromQuery] List<WorkOrderType>? types = null,
[FromQuery] string? search = null)
{
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(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet("board/search")]
public async Task<IActionResult> 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 });
}
}
[HttpGet("lookups/dispatchers")]
public async Task<IActionResult> GetDispatcherLookups()
{
var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
return Ok(result);
}
[HttpPatch("{id:int}/board")]
public async Task<IActionResult> 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)
{
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<IActionResult> 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)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
}
[HttpPost("{id:int}/cancel")]
public async Task<IActionResult> CancelBoardWorkOrder(int id)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCancelService.CancelAsync(id, actorId);
return Ok(row);
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
}
[HttpGet("{id:int}/detail")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
[HttpPatch("{id:int}/comments/{commentId:int}")]
public async Task<IActionResult> UpdateBoardComment(
int id,
int commentId,
[FromBody] WorkOrderCommentCreateDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var isAdmin = User.IsInRole("Admin");
var comment = await _workOrderCommentService.UpdateCommentAsync(
id, commentId, request, actorId, isAdmin);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
[HttpPost]
[Route("AddWorkorder")]
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)
@ -295,6 +697,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
[Authorize(Roles = "Admin")]
[HttpDelete]
[Route("DeleteWorkorder")]
public async Task<IActionResult> Deleteworkorder([FromBody] EditWorkorder_DTO model)

View file

@ -0,0 +1,50 @@
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[ApiController]
[Route("api/workorders")]
public class WorkOrderIngestController : ControllerBase
{
private const int MaxBatchSize = 100;
private readonly IWorkOrderIngestService _ingestService;
public WorkOrderIngestController(IWorkOrderIngestService ingestService)
{
_ingestService = ingestService;
}
/// <summary>
/// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target).
/// Auth: X-Ingest-Key header.
/// </summary>
[HttpPost("ingest")]
[IngestApiKey]
[ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)]
public async Task<ActionResult<WorkOrderIngestBatchResultDto>> Ingest(
[FromBody] WorkOrderIngestBatchRequestDto? batch,
CancellationToken cancellationToken)
{
var items = NormalizeItems(batch);
if (items.Count == 0)
return BadRequest("At least one item with externalWorkOrderId is required.");
if (items.Count > MaxBatchSize)
return BadRequest($"Max {MaxBatchSize} items per request.");
var result = await _ingestService.UpsertBatchAsync(items, cancellationToken);
return Ok(result);
}
private static List<WorkOrderIngestPayloadDto> NormalizeItems(WorkOrderIngestBatchRequestDto? batch)
{
if (batch?.Items is { Count: > 0 })
return batch.Items;
return new List<WorkOrderIngestPayloadDto>();
}
}
}

View file

@ -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
{
/// <summary>Ops-only endpoints for manual domain job reprocessing.</summary>
[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;
}
/// <summary>
/// Reprocess WeekRolled for a specific operational week (Monday start).
/// Idempotent via WorkOrderWeekRolledLedger.
/// </summary>
[HttpPost("week-rolled")]
[ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)]
public async Task<ActionResult<WeekRolledJobResult>> 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);
}
/// <summary>
/// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read).
/// </summary>
[HttpPost("past-due-cache")]
[ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)]
public async Task<ActionResult<PastDueCacheJobResult>> RunPastDueCache(CancellationToken cancellationToken)
{
var result = await _pastDueCacheService.RefreshAsync(cancellationToken);
return Ok(result);
}
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
/// <summary>Ops health and coexistence metrics (Fase 7).</summary>
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/workorders/ops")]
public class WorkOrderOpsController : ControllerBase
{
private readonly IWorkOrderOpsHealthService _healthService;
public WorkOrderOpsController(IWorkOrderOpsHealthService healthService)
{
_healthService = healthService;
}
[HttpGet("health")]
[ProducesResponseType(typeof(WorkOrderOpsHealthDto), StatusCodes.Status200OK)]
public async Task<ActionResult<WorkOrderOpsHealthDto>> GetHealth(CancellationToken cancellationToken)
{
var health = await _healthService.GetHealthAsync(cancellationToken);
return Ok(health);
}
}
}

View file

@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Filters
{
public class ConcurrencyExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is not DbUpdateConcurrencyException) return;
context.Result = new ConflictObjectResult(new
{
status = "Conflict",
message = "The record was modified by another user. Refresh and retry.",
code = 409
});
context.ExceptionHandled = true;
}
}
}

View file

@ -0,0 +1,67 @@
using System.Security.Cryptography;
using System.Text;
using Api.SeaHavenIndustries.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
namespace Api.SeaHavenIndustries.Filters
{
/// <summary>Validates X-Ingest-Key for POST /api/workorders/ingest.</summary>
public class IngestApiKeyFilter : IAsyncActionFilter
{
private readonly WorkOrderIngestOptions _options;
public IngestApiKeyFilter(IOptions<WorkOrderIngestOptions> options)
{
_options = options.Value;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!_options.Enabled)
{
context.Result = new ObjectResult(new { message = "Work order ingest is disabled." })
{
StatusCode = StatusCodes.Status503ServiceUnavailable
};
return;
}
if (string.IsNullOrWhiteSpace(_options.ApiKey)
|| _options.ApiKey.Contains("${", StringComparison.Ordinal))
{
context.Result = new ObjectResult(new { message = "Ingest API key is not configured." })
{
StatusCode = StatusCodes.Status503ServiceUnavailable
};
return;
}
if (!context.HttpContext.Request.Headers.TryGetValue("X-Ingest-Key", out var provided)
|| !FixedTimeEquals(provided.ToString(), _options.ApiKey))
{
context.Result = new UnauthorizedObjectResult(new { message = "Invalid or missing X-Ingest-Key." });
return;
}
await next();
}
private static bool FixedTimeEquals(string provided, string expected)
{
var providedBytes = Encoding.UTF8.GetBytes(provided);
var expectedBytes = Encoding.UTF8.GetBytes(expected);
return providedBytes.Length == expectedBytes.Length
&& CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes);
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class IngestApiKeyAttribute : ServiceFilterAttribute
{
public IngestApiKeyAttribute() : base(typeof(IngestApiKeyFilter))
{
}
}
}

View file

@ -0,0 +1,76 @@
using Microsoft.Extensions.Options;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Options;
namespace Api.SeaHavenIndustries.HostedServices
{
public class PastDueCacheHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<PastDueCacheHostedService> _logger;
private readonly PastDueCacheJobOptions _options;
private readonly WorkOrderJobRunState _runState;
private DateOnly? _lastRunDate;
public PastDueCacheHostedService(
IServiceScopeFactory scopeFactory,
IOptions<WorkOrderJobsOptions> options,
WorkOrderJobRunState runState,
ILogger<PastDueCacheHostedService> 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)
{
if (await RunJobAsync(stoppingToken))
_lastRunDate = today;
}
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
{
try
{
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IPastDueCacheService>();
await service.RefreshAsync(stoppingToken);
_runState.LastPastDueCacheRunUtc = DateTime.UtcNow;
_runState.LastPastDueCacheError = null;
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runState.LastPastDueCacheError = ex.Message;
_logger.LogError(ex, "PastDue cache hosted job failed.");
return false;
}
}
private bool ShouldRunNow()
{
var now = DateTime.UtcNow;
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
runAt = new TimeOnly(0, 10);
return TimeOnly.FromDateTime(now) >= runAt;
}
}
}

View file

@ -0,0 +1,84 @@
using Microsoft.Extensions.Options;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Options;
namespace Api.SeaHavenIndustries.HostedServices
{
public class WorkOrderWeekRolledHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<WorkOrderWeekRolledHostedService> _logger;
private readonly WeekRolledJobOptions _options;
private readonly WorkOrderJobRunState _runState;
private DateOnly? _lastRunDate;
public WorkOrderWeekRolledHostedService(
IServiceScopeFactory scopeFactory,
IOptions<WorkOrderJobsOptions> options,
WorkOrderJobRunState runState,
ILogger<WorkOrderWeekRolledHostedService> 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)
{
if (await RunJobAsync(stoppingToken))
_lastRunDate = today;
}
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
{
try
{
var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow);
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderWeekRolledService>();
await service.ProcessWeekRolledAsync(sourceWeekStart, stoppingToken);
_runState.LastWeekRolledRunUtc = DateTime.UtcNow;
_runState.LastWeekRolledError = null;
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runState.LastWeekRolledError = ex.Message;
_logger.LogError(ex, "WeekRolled hosted job failed.");
return false;
}
}
private bool ShouldRunNow()
{
var now = DateTime.UtcNow;
if (!Enum.TryParse<DayOfWeek>(_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);
return TimeOnly.FromDateTime(now) >= runAt;
}
}
}

View file

@ -0,0 +1,57 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.Extensions.Options;
namespace Api.SeaHavenIndustries.Middleware
{
/// <summary>Adds Sunset/Deprecation headers to legacy WorkOrder REST routes.</summary>
public class LegacyDeprecationMiddleware
{
private static readonly HashSet<string> LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase)
{
"GetWorkOrderList",
"AddWorkorder",
"EditWorkorder",
"ChangeStatus",
"ChangeAssignment",
"DeleteWorkorder",
"GetWorkorderById"
};
private readonly RequestDelegate _next;
private readonly LegacyEndpointOptions _options;
public LegacyDeprecationMiddleware(RequestDelegate next, IOptions<LegacyEndpointOptions> options)
{
_next = next;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context)
{
if (_options.DeprecationEnabled)
{
var path = context.Request.Path.Value ?? "";
var isLegacyWo = (path.Contains("/WorkOrder", StringComparison.OrdinalIgnoreCase)
|| path.Contains("/workorders", StringComparison.OrdinalIgnoreCase))
&& LegacyPathSegments.Any(seg => path.Contains(seg, StringComparison.OrdinalIgnoreCase));
if (isLegacyWo)
{
context.Response.OnStarting(() =>
{
context.Response.Headers["Deprecation"] = "true";
if (!string.IsNullOrWhiteSpace(_options.SunsetDate)
&& DateTimeOffset.TryParse(_options.SunsetDate, out var sunset))
{
context.Response.Headers["Sunset"] = sunset.UtcDateTime.ToString("r");
}
context.Response.Headers["Link"] = "</api/workorders/board>; rel=\"successor-version\"";
return Task.CompletedTask;
});
}
}
await _next(context);
}
}
}

View file

@ -0,0 +1,12 @@
namespace Api.SeaHavenIndustries.Options
{
public class LegacyEndpointOptions
{
public const string SectionName = "LegacyEndpoints";
public bool DeprecationEnabled { get; set; }
/// <summary>ISO date returned in Sunset header (e.g. 2026-12-31).</summary>
public string? SunsetDate { get; set; }
}
}

View file

@ -0,0 +1,10 @@
namespace Api.SeaHavenIndustries.Options
{
public class SyncOptions
{
public const string SectionName = "Sync";
/// <summary>When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest.</summary>
public bool Enabled { get; set; }
}
}

View file

@ -0,0 +1,13 @@
namespace Api.SeaHavenIndustries.Options
{
public class WorkOrderIngestOptions
{
public const string SectionName = "WorkOrderIngest";
/// <summary>When false, ingest endpoints return 503. Default false until a real ApiKey is provisioned.</summary>
public bool Enabled { get; set; }
/// <summary>Shared secret for X-Ingest-Key header (Lambda / service accounts).</summary>
public string? ApiKey { get; set; }
}
}

View file

@ -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";
}
}

View file

@ -1,4 +1,7 @@
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
@ -9,6 +12,7 @@ using Microsoft.OpenApi.Models;
using System.Text;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
var builder = WebApplication.CreateBuilder(args);
ConfigurationManager configuration = builder.Configuration;
@ -22,7 +26,10 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddControllers().AddJsonOptions(options =>
builder.Services.AddControllers(options =>
{
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
});
@ -48,6 +55,38 @@ builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp
builder.Services.AddDataServices();
builder.Services.AddBusinessServices();
builder.Services.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
builder.Services.AddOptions<WorkOrderIngestOptions>()
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
.Validate(
o => !o.Enabled
|| (!string.IsNullOrWhiteSpace(o.ApiKey)
&& !o.ApiKey.Contains("${", StringComparison.Ordinal)
&& o.ApiKey.Length >= 32),
"WorkOrderIngest:ApiKey must be a real secret (env/user-secrets, length >= 32) when ingest is enabled.")
.ValidateOnStart();
builder.Services.Configure<SyncOptions>(
builder.Configuration.GetSection(SyncOptions.SectionName));
builder.Services.Configure<LegacyEndpointOptions>(
builder.Configuration.GetSection(LegacyEndpointOptions.SectionName));
builder.Services.AddSingleton<WorkOrderJobRunStateAccessor>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>().State);
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
builder.Services.AddHostedService<PastDueCacheHostedService>();
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,
Microsoft.Extensions.Options.IOptions<LegacyEndpointOptions>>(
(health, sync, ingest, legacy) =>
{
health.SyncEnabled = sync.Value.Enabled;
health.IngestEnabled = ingest.Value.Enabled;
health.LegacyDeprecationEnabled = legacy.Value.DeprecationEnabled;
health.LegacySunsetDate = legacy.Value.SunsetDate;
});
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
@ -113,6 +152,7 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCors();
app.UseMiddleware<LegacyDeprecationMiddleware>();
app.UseAuthentication();
app.UseAuthorization();

View file

@ -1,4 +1,4 @@
{
{
"ConnectionStrings": {
//"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true",
@ -23,5 +23,22 @@
"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" }
},
"WorkOrderIngest": {
"Enabled": false,
"ApiKey": "${WORKORDER_INGEST_API_KEY}"
},
"Sync": {
"Enabled": true
},
"LegacyEndpoints": {
"DeprecationEnabled": false,
"SunsetDate": "2026-12-31"
},
"FrontendBaseUrl": "https://shoc.seahaven.com"
}

View file

@ -33,6 +33,77 @@ namespace Data.SeaHavenIndustries
.HasIndex(u => new { u.DispatchId, u.Status })
.IsUnique()
.HasFilter("[Status] = 'Pending'");
builder.Entity<WorkOrderFieldLock>()
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
.IsUnique();
builder.Entity<WorkOrderWeekRolledLedger>()
.HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart })
.IsUnique();
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
.IsRowVersion();
builder.Entity<WorkOrder>()
.Property(w => w.SiteCode)
.HasMaxLength(450);
builder.Entity<WorkOrder>()
.HasIndex(w => w.SiteCode)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.Property(w => w.InternalWONumber)
.HasMaxLength(450);
builder.Entity<WorkOrder>()
.HasIndex(w => w.InternalWONumber)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0");
// Phase 1 board indexes (must stay in model to match snapshot / existing DBs).
builder.Entity<WorkOrder>()
.HasIndex(w => w.AssignTo);
builder.Entity<WorkOrder>()
.HasIndex(w => w.ScheduledDate);
builder.Entity<WorkOrder>()
.HasIndex(w => new { w.AssignTo, w.ScheduledDate });
builder.Entity<WorkOrder>()
.HasIndex(w => w.WorkOrderType);
builder.Entity<WorkOrder>()
.Property(w => w.Service)
.HasMaxLength(128);
builder.Entity<WorkOrder>()
.Property(w => w.ExternalWorkOrderId)
.HasMaxLength(450);
builder.Entity<WorkOrder>()
.HasIndex(w => w.ExternalWorkOrderId)
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
builder.Entity<Dispatch>()
.Property(d => d.RowVersion)
.IsRowVersion();
builder.Entity<WorkOrder>()
.HasOne(w => w.PrimaryDispatch)
.WithMany()
.HasForeignKey(w => w.PrimaryDispatchId)
.OnDelete(DeleteBehavior.Restrict);
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -46,7 +117,10 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderContacts> WorkOrderContacts { get; set; }
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
@ -75,6 +149,51 @@ namespace Data.SeaHavenIndustries
public DbSet<Department> Departments { get; set; }
public DbSet<JobTitle> JobTitles { get; set; }
public DbSet<Region> Regions { get; set; }
public override int SaveChanges()
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChangesAsync(cancellationToken);
}
private void BumpRowVersionsForInMemoryProvider()
{
if (!Database.ProviderName?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) ?? true)
return;
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
foreach (var entry in ChangeTracker.Entries<Dispatch>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
}
private static byte[] IncrementRowVersion(byte[]? current)
{
var bytes = current != null && current.Length > 0
? (byte[])current.Clone()
: new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
for (var i = 0; i < bytes.Length; i++)
{
if (++bytes[i] != 0)
break;
}
return bytes;
}
}
public class ApplicationUser : IdentityUser
@ -88,6 +207,8 @@ namespace Data.SeaHavenIndustries
public DateTime? DeletedDate { get; set; }
public bool? IsDeleted { get; set; }
public string? UserImage { get; set; }
public string? Initials { get; set; }
public string? Color { get; set; }
public int? Type { get; set; } // 1 for users 0 for admin
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? WorkOrders { get; set; }

View file

@ -1,92 +1,171 @@
namespace Data.SeaHavenIndustries.Enums
{
public enum WorkOrderStatus
{
Open = 1,
InProgress = 2,
Completed = 3,
Cancelled = 4,
OnHold = 5
}
public enum WorkOrderPriority
{
Low = 1,
Medium = 2,
High = 3,
Critical = 4
}
public enum AuditAction
{
Create = 1,
Edit = 2,
Delete = 3,
StatusChange = 4,
AssignmentChange = 5,
Dispatch = 6,
DispatchUpdate = 7,
DispatchVerify = 8
}
public enum ContactType
{
Primary = 1,
Secondary = 2,
Billing = 3,
Technical = 4
}
public enum AssetStatus
{
Active = 1,
Inactive = 2,
UnderMaintenance = 3,
Retired = 4
}
public enum DispatchStatus
{
Pending = 1,
Assigned = 2,
InProgress = 3,
Completed = 4,
Cancelled = 5
}
public enum FollowUpStatus
{
Open = 1,
InProgress = 2,
Resolved = 3,
Closed = 4
}
public enum PMScheduleFrequency
{
Daily = 1,
Weekly = 2,
Monthly = 3,
Quarterly = 4,
SemiAnnual = 5,
Annual = 6
}
public enum CommentType
{
General = 1,
Internal = 2,
Customer = 3,
Vendor = 4,
System = 5
}
public enum RecordType
{
WorkOrder = 1,
Dispatch = 2,
Quote = 3,
Contact = 4,
Asset = 5
}
}
namespace Data.SeaHavenIndustries.Enums
{
/// <summary>Legacy enum — prefer LifecycleStatus for board operations.</summary>
public enum WorkOrderStatus
{
Open = 1,
InProgress = 2,
Completed = 3,
Cancelled = 4,
OnHold = 5
}
/// <summary>10 operational lifecycle values aligned with SHOC board (FE labels).</summary>
public enum LifecycleStatus
{
Incomplete = 1,
Pending = 2,
Scheduled = 3,
EnRoute = 4,
OnSite = 5,
InProgress = 6,
Completed = 7,
Rescheduled = 8,
Canceled = 9,
PendingQuote = 10
}
/// <summary>Board work-order types. Legacy values retained for backward compatibility.</summary>
public enum WorkOrderType
{
PO = 1,
PM = 2,
Emergency = 3,
Project = 4,
Inspection = 5,
Reactive = 6,
AddOn = 7,
Other = 99
}
public enum DocStatus
{
Yes = 1,
No = 2,
NotNeeded = 3
}
[Flags]
public enum OperationalFlags
{
None = 0,
PastDue = 1
}
public enum AuditEventType
{
Manual = 1,
System = 2,
Sync = 3,
Vendor = 4
}
public enum AuditActorType
{
Dispatcher = 1,
Vendor = 2,
System = 3,
Sync = 4
}
public enum AuditActionType
{
FieldChanged = 1,
StatusChanged = 2,
AssignmentChanged = 3,
WeekRolled = 4,
SyncRejected = 5,
Create = 6,
Delete = 7
}
public enum WorkOrderPriority
{
Low = 1,
Medium = 2,
High = 3,
Critical = 4
}
public enum AuditAction
{
Create = 1,
Edit = 2,
Delete = 3,
StatusChange = 4,
AssignmentChange = 5,
Dispatch = 6,
DispatchUpdate = 7,
DispatchVerify = 8
}
public enum ContactType
{
Primary = 1,
Secondary = 2,
Billing = 3,
Technical = 4
}
public enum AssetStatus
{
Active = 1,
Inactive = 2,
UnderMaintenance = 3,
Retired = 4
}
public enum DispatchStatus
{
Pending = 1,
Assigned = 2,
InProgress = 3,
Completed = 4,
Cancelled = 5
}
public enum FollowUpStatus
{
Open = 1,
InProgress = 2,
Resolved = 3,
Closed = 4
}
public enum PMScheduleFrequency
{
Daily = 1,
Weekly = 2,
Monthly = 3,
Quarterly = 4,
SemiAnnual = 5,
Annual = 6
}
public enum CommentType
{
General = 1,
Internal = 2,
Customer = 3,
Vendor = 4,
System = 5
}
public enum RecordType
{
WorkOrder = 1,
Dispatch = 2,
Quote = 3,
Contact = 4,
Asset = 5
}
public enum WorkOrderMediaCategory
{
Before = 1,
After = 2,
Extra = 3,
Completion = 4
}
}

View file

@ -0,0 +1,43 @@
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;
""");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,744 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase0_DomainFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Dispatches_workOrders_WorkOrderId",
table: "Dispatches");
migrationBuilder.DropForeignKey(
name: "FK_Locations_Accounts_AccountId",
table: "Locations");
migrationBuilder.DropIndex(
name: "IX_Locations_AccountId",
table: "Locations");
migrationBuilder.DropColumn(
name: "CreatedDate",
table: "Regions");
migrationBuilder.DropColumn(
name: "DeleterUserId",
table: "Regions");
migrationBuilder.DropColumn(
name: "DeletionTime",
table: "Regions");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "Regions");
migrationBuilder.DropColumn(
name: "LastModificationTime",
table: "Regions");
migrationBuilder.DropColumn(
name: "LastModifierUserId",
table: "Regions");
migrationBuilder.DropColumn(
name: "createdby",
table: "Regions");
migrationBuilder.DropColumn(
name: "AccountId",
table: "Locations");
migrationBuilder.DropColumn(
name: "CreatedDate",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "DeleterUserId",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "DeletionTime",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "LastModificationTime",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "LastModifierUserId",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "createdby",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "CreatedDate",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "DeleterUserId",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "DeletionTime",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "LastModificationTime",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "LastModifierUserId",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "createdby",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "CreatedDate",
table: "Departments");
migrationBuilder.DropColumn(
name: "DeleterUserId",
table: "Departments");
migrationBuilder.DropColumn(
name: "DeletionTime",
table: "Departments");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "Departments");
migrationBuilder.DropColumn(
name: "LastModificationTime",
table: "Departments");
migrationBuilder.DropColumn(
name: "LastModifierUserId",
table: "Departments");
migrationBuilder.DropColumn(
name: "createdby",
table: "Departments");
migrationBuilder.RenameColumn(
name: "ZipCode",
table: "Locations",
newName: "Zip");
migrationBuilder.RenameColumn(
name: "Phone",
table: "Locations",
newName: "PhoneNumber");
migrationBuilder.RenameColumn(
name: "ContactEmail",
table: "Locations",
newName: "Email");
migrationBuilder.RenameColumn(
name: "Contact",
table: "Locations",
newName: "Address2");
migrationBuilder.RenameColumn(
name: "Address",
table: "Locations",
newName: "Address1");
migrationBuilder.AddColumn<DateOnly>(
name: "AssignDate",
table: "workOrders",
type: "date",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AvettaTask",
table: "workOrders",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CarriedOver",
table: "workOrders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "DocStatus",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LegacyStatus",
table: "workOrders",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LifecycleStatus",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OperationalFlags",
table: "workOrders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<DateOnly>(
name: "OriginalDate",
table: "workOrders",
type: "date",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "OriginalWeek",
table: "workOrders",
type: "date",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PrimaryDispatchId",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RescheduleCount",
table: "workOrders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "workOrders",
type: "rowversion",
rowVersion: true,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "ScheduleWeekOnly",
table: "workOrders",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ScheduledEnd",
table: "workOrders",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "TargetWeek",
table: "workOrders",
type: "date",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WorkOrderType",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Service",
table: "workOrders",
type: "nvarchar(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "WorkOrderContacts",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ActorType",
table: "WorkOrderAuditLogs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CorrelationId",
table: "WorkOrderAuditLogs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DispatchId",
table: "WorkOrderAuditLogs",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "EventType",
table: "WorkOrderAuditLogs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "Dispatches",
type: "rowversion",
rowVersion: true,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Color",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Initials",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "WorkOrderFieldLocks",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
FieldName = table.Column<string>(type: "nvarchar(450)", nullable: false),
LockedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LockedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkOrderFieldLocks", x => x.Id);
table.ForeignKey(
name: "FK_WorkOrderFieldLocks_workOrders_WorkOrderId",
column: x => x.WorkOrderId,
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_workOrders_PrimaryDispatchId",
table: "workOrders",
column: "PrimaryDispatchId");
migrationBuilder.CreateIndex(
name: "IX_WorkOrderFieldLocks_WorkOrderId_FieldName",
table: "WorkOrderFieldLocks",
columns: new[] { "WorkOrderId", "FieldName" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Dispatches_workOrders_WorkOrderId",
table: "Dispatches",
column: "WorkOrderId",
principalTable: "workOrders",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_workOrders_Dispatches_PrimaryDispatchId",
table: "workOrders",
column: "PrimaryDispatchId",
principalTable: "Dispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.Sql("""
UPDATE workOrders SET LegacyStatus = Status WHERE LegacyStatus IS NULL AND Status IS NOT NULL;
""");
migrationBuilder.Sql("""
UPDATE workOrders SET LifecycleStatus = CASE
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('open', 'incomplete') THEN 1
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending', 'onhold', 'on hold') THEN 2
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('scheduled') THEN 3
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('en route', 'enroute') THEN 4
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('on site', 'onsite') THEN 5
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('inprogress', 'in progress') THEN 6
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('completed', 'complete', 'done', 'closed') THEN 7
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('rescheduled') THEN 8
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('cancelled', 'canceled') THEN 9
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending quote', 'pendingquote', 'pendingapproval', 'pending approval') THEN 10
ELSE 1
END
WHERE LifecycleStatus IS NULL AND Status IS NOT NULL;
""");
migrationBuilder.Sql("""
UPDATE wo SET wo.PrimaryDispatchId = latest.DispatchId
FROM workOrders wo
INNER JOIN (
SELECT WorkOrderId, MAX(Id) AS DispatchId
FROM Dispatches
WHERE WorkOrderId IS NOT NULL
GROUP BY WorkOrderId
) latest ON wo.Id = latest.WorkOrderId
WHERE wo.PrimaryDispatchId IS NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Dispatches_workOrders_WorkOrderId",
table: "Dispatches");
migrationBuilder.DropForeignKey(
name: "FK_workOrders_Dispatches_PrimaryDispatchId",
table: "workOrders");
migrationBuilder.DropTable(
name: "WorkOrderFieldLocks");
migrationBuilder.DropIndex(
name: "IX_workOrders_PrimaryDispatchId",
table: "workOrders");
migrationBuilder.DropColumn(
name: "AssignDate",
table: "workOrders");
migrationBuilder.DropColumn(
name: "AvettaTask",
table: "workOrders");
migrationBuilder.DropColumn(
name: "CarriedOver",
table: "workOrders");
migrationBuilder.DropColumn(
name: "DocStatus",
table: "workOrders");
migrationBuilder.DropColumn(
name: "LegacyStatus",
table: "workOrders");
migrationBuilder.DropColumn(
name: "LifecycleStatus",
table: "workOrders");
migrationBuilder.DropColumn(
name: "OperationalFlags",
table: "workOrders");
migrationBuilder.DropColumn(
name: "OriginalDate",
table: "workOrders");
migrationBuilder.DropColumn(
name: "OriginalWeek",
table: "workOrders");
migrationBuilder.DropColumn(
name: "PrimaryDispatchId",
table: "workOrders");
migrationBuilder.DropColumn(
name: "RescheduleCount",
table: "workOrders");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ScheduleWeekOnly",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ScheduledEnd",
table: "workOrders");
migrationBuilder.DropColumn(
name: "TargetWeek",
table: "workOrders");
migrationBuilder.DropColumn(
name: "WorkOrderType",
table: "workOrders");
migrationBuilder.DropColumn(
name: "Service",
table: "workOrders");
migrationBuilder.DropColumn(
name: "Notes",
table: "WorkOrderContacts");
migrationBuilder.DropColumn(
name: "ActorType",
table: "WorkOrderAuditLogs");
migrationBuilder.DropColumn(
name: "CorrelationId",
table: "WorkOrderAuditLogs");
migrationBuilder.DropColumn(
name: "DispatchId",
table: "WorkOrderAuditLogs");
migrationBuilder.DropColumn(
name: "EventType",
table: "WorkOrderAuditLogs");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "Color",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "Initials",
table: "AspNetUsers");
migrationBuilder.RenameColumn(
name: "Zip",
table: "Locations",
newName: "ZipCode");
migrationBuilder.RenameColumn(
name: "PhoneNumber",
table: "Locations",
newName: "Phone");
migrationBuilder.RenameColumn(
name: "Email",
table: "Locations",
newName: "ContactEmail");
migrationBuilder.RenameColumn(
name: "Address2",
table: "Locations",
newName: "Contact");
migrationBuilder.RenameColumn(
name: "Address1",
table: "Locations",
newName: "Address");
migrationBuilder.AddColumn<DateTime>(
name: "CreatedDate",
table: "Regions",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeleterUserId",
table: "Regions",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletionTime",
table: "Regions",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "Regions",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastModificationTime",
table: "Regions",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LastModifierUserId",
table: "Regions",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "createdby",
table: "Regions",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AccountId",
table: "Locations",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedDate",
table: "JobTitles",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeleterUserId",
table: "JobTitles",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletionTime",
table: "JobTitles",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "JobTitles",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastModificationTime",
table: "JobTitles",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LastModifierUserId",
table: "JobTitles",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "createdby",
table: "JobTitles",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedDate",
table: "EmployeeEmails",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeleterUserId",
table: "EmployeeEmails",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletionTime",
table: "EmployeeEmails",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "EmployeeEmails",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastModificationTime",
table: "EmployeeEmails",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LastModifierUserId",
table: "EmployeeEmails",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "createdby",
table: "EmployeeEmails",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedDate",
table: "Departments",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeleterUserId",
table: "Departments",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletionTime",
table: "Departments",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "Departments",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastModificationTime",
table: "Departments",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LastModifierUserId",
table: "Departments",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "createdby",
table: "Departments",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Locations_AccountId",
table: "Locations",
column: "AccountId");
migrationBuilder.AddForeignKey(
name: "FK_Dispatches_workOrders_WorkOrderId",
table: "Dispatches",
column: "WorkOrderId",
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Locations_Accounts_AccountId",
table: "Locations",
column: "AccountId",
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase4_SearchIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// nvarchar(max) cannot be an index key; shrink before indexing.
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(450)",
maxLength: 450,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
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",
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");
}
/// <inheritdoc />
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");
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldMaxLength: 450,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "InternalWONumber",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldMaxLength: 450,
oldNullable: true);
}
}
}

View file

@ -0,0 +1,88 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260625120000_Phase6_CompletionSlideOver")]
public partial class Phase6_CompletionSlideOver : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL
BEGIN
ALTER TABLE workOrderAttachments ADD Category int NULL;
END
");
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NULL
BEGIN
CREATE TABLE CompletionDocTemplates (
Id int NOT NULL IDENTITY(1,1),
Name nvarchar(max) NOT NULL,
ServiceKey nvarchar(450) NOT NULL,
WorkOrderType int NULL,
TemplateUrl nvarchar(max) NOT NULL,
IsActive bit NOT NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL,
CONSTRAINT PK_CompletionDocTemplates PRIMARY KEY (Id)
);
END
");
migrationBuilder.Sql(@"
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'IX_CompletionDocTemplates_ServiceKey_IsActive'
AND object_id = OBJECT_ID(N'dbo.CompletionDocTemplates'))
BEGIN
CREATE INDEX IX_CompletionDocTemplates_ServiceKey_IsActive
ON CompletionDocTemplates (ServiceKey, IsActive);
END
");
migrationBuilder.Sql(@"
UPDATE workOrders
SET DocStatus = 1
WHERE DocStatus IS NULL
AND SignOffAttachment IS NOT NULL
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
UPDATE workOrders
SET DocStatus = NULL
WHERE DocStatus = 1
AND SignOffAttachment IS NOT NULL
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
");
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL
DROP TABLE CompletionDocTemplates;
");
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL
ALTER TABLE workOrderAttachments DROP COLUMN Category;
");
}
}
}

View file

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public class Phase1_BoardIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
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
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
BEGIN
CREATE INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders (AssignTo, ScheduledDate);
END
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_WorkOrderType'
AND object_id = OBJECT_ID('workOrders'))
BEGIN
CREATE INDEX IX_workOrders_WorkOrderType ON workOrders (WorkOrderType);
END
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_WorkOrderType'
AND object_id = OBJECT_ID('workOrders'))
DROP INDEX IX_workOrders_WorkOrderType ON workOrders;
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
DROP INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders;
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;
""");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase5_DomainEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WorkOrderWeekRolledLedgers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
SourceWeekStart = table.Column<DateOnly>(type: "date", nullable: false),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CorrelationId = table.Column<string>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkOrderWeekRolledLedgers");
}
}
}

View file

@ -0,0 +1,63 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260713120000_Phase7_ExternalWorkOrderIdUnique")]
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Idempotent: column may already be nvarchar(450) in some environments.
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrders', 'ExternalWorkOrderId') IS NOT NULL
AND EXISTS (
SELECT 1
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'dbo.workOrders')
AND c.name = N'ExternalWorkOrderId'
AND t.name = N'nvarchar'
AND c.max_length = -1)
BEGIN
ALTER TABLE workOrders ALTER COLUMN ExternalWorkOrderId nvarchar(450) NULL;
END
");
migrationBuilder.Sql(@"
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'IX_workOrders_ExternalWorkOrderId'
AND object_id = OBJECT_ID(N'dbo.workOrders'))
BEGIN
CREATE UNIQUE INDEX IX_workOrders_ExternalWorkOrderId
ON workOrders (ExternalWorkOrderId)
WHERE [ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> '';
END
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'IX_workOrders_ExternalWorkOrderId'
AND object_id = OBJECT_ID(N'dbo.workOrders'))
DROP INDEX IX_workOrders_ExternalWorkOrderId ON workOrders;
");
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrders', 'ExternalWorkOrderId') IS NOT NULL
ALTER TABLE workOrders ALTER COLUMN ExternalWorkOrderId nvarchar(max) NULL;
");
}
}
}

View file

@ -0,0 +1,121 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260715160000_WoServiceNotesStructuredFields")]
public partial class WoServiceNotesStructuredFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PocName",
table: "workOrders",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PocPhone",
table: "workOrders",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PocNotes",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExtraServices",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ServiceNotes",
table: "workOrders",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TechPhone",
table: "workOrders",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VendorNotes",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TechPhone",
table: "Dispatches",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VendorNotes",
table: "Dispatches",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PocName",
table: "workOrders");
migrationBuilder.DropColumn(
name: "PocPhone",
table: "workOrders");
migrationBuilder.DropColumn(
name: "PocNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ExtraServices",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ServiceNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "TechPhone",
table: "workOrders");
migrationBuilder.DropColumn(
name: "VendorNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "TechPhone",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "VendorNotes",
table: "Dispatches");
}
}
}

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260715180000_WoFlagColor")]
public partial class WoFlagColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FlagColor",
table: "workOrders",
type: "nvarchar(7)",
maxLength: 7,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FlagColor",
table: "workOrders");
}
}
}

View file

@ -0,0 +1,58 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260717140000_AddEventsCalendarTable")]
public partial class AddEventsCalendarTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Events entity existed in the model/DbSet without a discoverable migration.
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.Events', N'U') IS NULL
BEGIN
CREATE TABLE Events (
Id int NOT NULL IDENTITY(1,1),
Title nvarchar(100) NOT NULL,
Description nvarchar(max) NULL,
Location nvarchar(150) NULL,
RecordID int NULL,
RecordName nvarchar(max) NULL,
RecordTargetType nvarchar(max) NULL,
ReminderMinutes int NOT NULL,
EventColor nvarchar(20) NOT NULL,
StartDate datetime2 NOT NULL,
StartTime nvarchar(10) NULL,
EndDate datetime2 NOT NULL,
EndTime nvarchar(10) NULL,
AllDay bit NOT NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL,
CONSTRAINT PK_Events PRIMARY KEY (Id)
);
END
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.Events', N'U') IS NOT NULL
DROP TABLE Events;
");
}
}
}

View file

@ -186,6 +186,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("Color")
.HasColumnType("nvarchar(max)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
@ -209,6 +212,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("FirstName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Initials")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -435,6 +441,60 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Comments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ServiceKey")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TemplateUrl")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("ServiceKey", "IsActive");
b.ToTable("CompletionDocTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.Property<int>("Id")
@ -601,32 +661,11 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Departments");
@ -688,15 +727,28 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("ReplyToAddress")
.HasColumnType("nvarchar(max)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<DateTime?>("ScheduledDate")
.HasColumnType("datetime2");
b.Property<string>("Status")
.HasColumnType("nvarchar(max)");
b.Property<string>("TechPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<string>("VendorNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("datetime2");
@ -1111,36 +1163,15 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<int?>("EmailTypeId")
.HasColumnType("int");
b.Property<int>("EmployeeId")
.HasColumnType("int");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<bool?>("IsPrimary")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("EmployeeId");
@ -1178,6 +1209,86 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("EmployeePhones");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AllDay")
.HasColumnType("bit");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("EndDate")
.HasColumnType("datetime2");
b.Property<string>("EndTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("EventColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Location")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<int?>("RecordID")
.HasColumnType("int");
b.Property<string>("RecordName")
.HasColumnType("nvarchar(max)");
b.Property<string>("RecordTargetType")
.HasColumnType("nvarchar(max)");
b.Property<int>("ReminderMinutes")
.HasColumnType("int");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.Property<string>("StartTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Events");
});
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
{
b.Property<int>("Id")
@ -1279,32 +1390,11 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("JobTitles");
@ -1318,21 +1408,15 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("Address1")
.HasColumnType("nvarchar(max)");
b.Property<string>("Address")
b.Property<string>("Address2")
.HasColumnType("nvarchar(max)");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
b.Property<string>("Contact")
.HasColumnType("nvarchar(max)");
b.Property<string>("ContactEmail")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
@ -1342,6 +1426,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1360,7 +1447,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("State")
@ -1372,7 +1459,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Title")
.HasColumnType("nvarchar(max)");
b.Property<string>("ZipCode")
b.Property<string>("Zip")
.HasColumnType("nvarchar(max)");
b.Property<string>("createdby")
@ -1380,8 +1467,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Locations");
});
@ -1573,32 +1658,11 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Regions");
@ -1931,12 +1995,18 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("AfterPhotoIssue")
.HasColumnType("nvarchar(max)");
b.Property<DateOnly?>("AssignDate")
.HasColumnType("date");
b.Property<string>("AssignTo")
.HasColumnType("nvarchar(450)");
b.Property<string>("Attachments")
.HasColumnType("nvarchar(max)");
b.Property<string>("AvettaTask")
.HasColumnType("nvarchar(max)");
b.Property<string>("BeforPhotoAttachment")
.HasColumnType("nvarchar(max)");
@ -1946,6 +2016,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Building")
.HasColumnType("nvarchar(max)");
b.Property<int>("CarriedOver")
.HasColumnType("int");
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
@ -1967,14 +2040,27 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<int?>("DocStatus")
.HasColumnType("int");
b.Property<DateTime?>("DueDate")
.HasColumnType("datetime2");
b.Property<string>("ExternalWorkOrderId")
.HasColumnType("nvarchar(max)");
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("ExtraServices")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("FlagColor")
.HasMaxLength(7)
.HasColumnType("nvarchar(7)");
b.Property<string>("InternalWONumber")
.HasColumnType("nvarchar(max)");
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1985,24 +2071,76 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("LegacyStatus")
.HasColumnType("nvarchar(max)");
b.Property<int?>("LifecycleStatus")
.HasColumnType("int");
b.Property<int?>("LocationId")
.HasColumnType("int");
b.Property<int>("OperationalFlags")
.HasColumnType("int");
b.Property<DateOnly?>("OriginalDate")
.HasColumnType("date");
b.Property<DateOnly?>("OriginalWeek")
.HasColumnType("date");
b.Property<string>("PO")
.HasColumnType("nvarchar(max)");
b.Property<string>("PocName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PocNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("PocPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("PrimaryDispatchId")
.HasColumnType("int");
b.Property<string>("Priority")
.HasColumnType("nvarchar(max)");
b.Property<string>("Problem")
.HasColumnType("nvarchar(max)");
b.Property<int>("RescheduleCount")
.HasColumnType("int");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<bool?>("ScheduleWeekOnly")
.HasColumnType("bit");
b.Property<DateTime?>("ScheduledDate")
.HasColumnType("datetime2");
b.Property<DateTime?>("ScheduledEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("ScheduledStart")
.HasColumnType("datetime2");
b.Property<string>("Service")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ServiceNotes")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
@ -2016,7 +2154,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasColumnType("nvarchar(max)");
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");
@ -2033,12 +2172,26 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("TT")
.HasColumnType("nvarchar(max)");
b.Property<DateOnly?>("TargetWeek")
.HasColumnType("date");
b.Property<string>("TechPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Trade")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("VendorNTE")
.HasColumnType("decimal(18,2)");
b.Property<string>("VendorNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("WorkerOrderNumber")
.HasColumnType("nvarchar(max)");
@ -2055,8 +2208,29 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AssignTo");
b.HasIndex("ExternalWorkOrderId")
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
b.HasIndex("InternalWONumber")
.HasFilter("[istemplate] = 0");
b.HasIndex("LocationId");
b.HasIndex("PrimaryDispatchId");
b.HasIndex("ScheduledDate");
b.HasIndex("SiteCode")
.HasFilter("[istemplate] = 0");
b.HasIndex("WorkOrderType");
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("LifecycleStatus", "ScheduledDate")
.HasFilter("[istemplate] = 0");
b.ToTable("workOrders");
});
@ -2071,6 +2245,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Attachments")
.HasColumnType("nvarchar(max)");
b.Property<int?>("Category")
.HasColumnType("int");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
@ -2113,9 +2290,21 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Action")
.HasColumnType("nvarchar(max)");
b.Property<string>("ActorType")
.HasColumnType("nvarchar(max)");
b.Property<string>("CorrelationId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int?>("DispatchId")
.HasColumnType("int");
b.Property<string>("EventType")
.HasColumnType("nvarchar(max)");
b.Property<string>("FieldName")
.HasColumnType("nvarchar(max)");
@ -2213,6 +2402,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<int?>("WorkorderId")
.HasColumnType("int");
@ -2228,6 +2420,64 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("WorkOrderContacts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("LockedAt")
.HasColumnType("datetime2");
b.Property<string>("LockedByUserId")
.HasColumnType("nvarchar(max)");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "FieldName")
.IsUnique();
b.ToTable("WorkOrderFieldLocks");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
@ -2457,8 +2707,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany("Dispatches")
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict);
.HasForeignKey("WorkOrderId");
b.Navigation("Vendor");
@ -2612,16 +2861,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
{
b.HasOne("Data.SeaHavenIndustries.Assets", "Asset")
@ -2731,9 +2970,16 @@ namespace Data.SeaHavenIndustries.Migrations
.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 =>
@ -2798,6 +3044,28 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany("FieldLocks")
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -2943,6 +3211,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Dispatches");
b.Navigation("FieldLocks");
b.Navigation("Quotes");
b.Navigation("WorkOrderCategories");

View file

@ -0,0 +1,13 @@
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
public class CompletionDocTemplate : FullAuditEntity
{
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
}
}

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
@ -26,6 +27,17 @@ namespace Data.SeaHavenIndustries
public string? AcceptToken { get; set; }
public string? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
/// <summary>Tech/crew phone for this dispatch (overrides Vendor.Phone on board).</summary>
[MaxLength(50)]
public string? TechPhone { get; set; }
/// <summary>Instructions for the vendor for this appointment/dispatch.</summary>
[MaxLength(2000)]
public string? VendorNotes { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }
public ICollection<Comments>? Comments { get; set; }
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }

View file

@ -1,15 +1,14 @@
using System;
using System.ComponentModel.DataAnnotations.Schema;
using static System.Runtime.InteropServices.JavaScript.JSType;
using System.ComponentModel.DataAnnotations.Schema;
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
public class WorkOrderAttachments : FullAuditEntity
{
public string? Attachments { get; set; }
public WorkOrderMediaCategory? Category { get; set; }
public int? WorkorderId { get; set; }
[ForeignKey(nameof(WorkorderId))]
public virtual WorkOrder? WorkOrder { get; set; }
}
}

View file

@ -15,6 +15,10 @@ namespace Data.SeaHavenIndustries
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string? Action { get; set; }
public string? EventType { get; set; }
public string? ActorType { get; set; }
public int? DispatchId { get; set; }
public string? CorrelationId { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View file

@ -12,6 +12,6 @@ namespace Data.SeaHavenIndustries
public int? WorkorderId { get; set; }
[ForeignKey(nameof(WorkorderId))]
public virtual WorkOrder? WorkOrder { get; set; }
public string? Notes { get; set; }
}
}

View file

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// ManualEditFlag — blocks Sync overwrite after first SHOC edit per field.
/// </summary>
public class WorkOrderFieldLock
{
public int Id { get; set; }
public int WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))]
public virtual WorkOrder? WorkOrder { get; set; }
public string FieldName { get; set; } = "";
public DateTime LockedAt { get; set; }
public string? LockedByUserId { get; set; }
}
}

View file

@ -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; } = "";
}
}

View file

@ -1,48 +1,101 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using static System.Runtime.InteropServices.JavaScript.JSType;
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
public class WorkOrder : FullAuditEntity
{
// --- Core slice ---
[MaxLength(450)]
public string? InternalWONumber { get; set; }
[MaxLength(450)]
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; }
[MaxLength(450)]
public string? SiteCode { get; set; }
public string? Building { get; set; }
public string? Severity { get; set; }
public DateTime? DateReported { get; set; }
public DateTime? DueDate { get; set; }
public string? AssignTo { get; set; }
[ForeignKey(nameof(AssignTo))]
public virtual ApplicationUser? AssignToUser { get; set; }
public int? LocationId { get; set; }
[ForeignKey(nameof(LocationId))]
public virtual Locations? Locations { get; set; }
public LifecycleStatus? LifecycleStatus { get; set; }
public string? LegacyStatus { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
[MaxLength(128)]
public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; }
// --- Service & Notes / POC (structured create) ---
[MaxLength(200)]
public string? PocName { get; set; }
[MaxLength(50)]
public string? PocPhone { get; set; }
[MaxLength(2000)]
public string? PocNotes { get; set; }
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
[MaxLength(2000)]
public string? ExtraServices { get; set; }
[MaxLength(4000)]
public string? ServiceNotes { get; set; }
/// <summary>Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.</summary>
[MaxLength(50)]
public string? TechPhone { get; set; }
/// <summary>Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.</summary>
[MaxLength(2000)]
public string? VendorNotes { get; set; }
// --- Scheduling slice ---
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; }
// --- Tracking slice (set-once) ---
public DateOnly? OriginalWeek { get; set; }
public DateOnly? OriginalDate { get; set; }
// --- Analytics slice ---
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
public OperationalFlags OperationalFlags { get; set; }
// --- Completion slice ---
public DocStatus? DocStatus { get; set; }
/// <summary>Board flag color (#RRGGBB from fixed palette). Null = no flag.</summary>
[MaxLength(7)]
public string? FlagColor { get; set; }
// --- Legacy SQL columns (drift reconciliation) ---
public string? AvettaTask { get; set; }
public DateOnly? AssignDate { get; set; }
// --- Legacy fields (retained for Blazor / coexistence) ---
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? ScheduledDate { 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 int? ContactId { get; set; }
//[ForeignKey(nameof(ContactId))]
//public virtual Contacts? POC { get; set; }
public string? AssignTo { get; set; }
[ForeignKey(nameof(AssignTo))]
public virtual ApplicationUser? AssignToUser { get; set; }
public DateTime? DueDate { get; set; }
public string? Priority { get; set; }
public string? Status { get; set; }
public int? LocationId { get; set; }
[ForeignKey(nameof(LocationId))]
public virtual Locations? Locations { get; set; }
//public int? CategoryId { get; set; }
//[ForeignKey(nameof(CategoryId))]
//public virtual Category? Category { get; set; }
public string? Attachments { get; set; }
public string? BeforPhotoIssue { get; set; }
public string? BeforPhotoAttachment { get; set; }
@ -51,11 +104,16 @@ namespace Data.SeaHavenIndustries
public string? SignOffName { get; set; }
public string? SignOffAttachment { get; set; }
public string? SignOffSignature { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }
public ICollection<Comments>? Comments { get; set; }
public ICollection<Quotes>? Quotes { get; set; }
public ICollection<WorkOrderContacts>? WorkOrderContacts { get; set; }
public ICollection<WorkOrderAttachments>? workOrderAttachments { get; set; }
public ICollection<WorkOrderCategories>? WorkOrderCategories { get; set; }
public ICollection<Dispatch>? Dispatches { get; set; }
public ICollection<WorkOrderFieldLock>? FieldLocks { get; set; }
}
}

View file

@ -0,0 +1,7 @@
namespace SeaHaven.DataServices.Board
{
public static class WorkOrderBoardConstants
{
public const int ClientSideThreshold = 300;
}
}

View file

@ -0,0 +1,10 @@
namespace SeaHaven.DataServices.DTOs
{
public record WorkOrderBoardQuery(
DateOnly ScheduledFrom,
DateOnly ScheduledTo,
string? Search,
IReadOnlyList<string>? AssigneeIds,
IReadOnlyList<string>? DbStatuses,
int? LocationId);
}

View file

@ -0,0 +1,20 @@
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);
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.DataServices.Exceptions
{
public class BoardQueryException : Exception
{
public BoardQueryException(string message) : base(message)
{
}
}
}

View file

@ -0,0 +1,127 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardProjection
{
public static async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
IQueryable<WorkOrder> query,
bool isUnscheduled)
{
var rows = await query
.Select(w => new
{
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
WoPocName = w.PocName,
WoPocPhone = w.PocPhone,
WoPocNotes = w.PocNotes,
ContactPoc = w.WorkOrderContacts!
.OrderBy(c => c.Id)
.Select(c => new
{
Name = c.POC != null
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
: null,
c.POC!.PhoneNumber,
c.Notes
})
.FirstOrDefault(),
w.LifecycleStatus,
w.LegacyStatus,
w.Status,
w.AssignTo,
w.AssignToUser!.FirstName,
w.AssignToUser!.LastName,
w.AssignToUser!.Initials,
w.AssignToUser!.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null,
VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.CompanyName
: null,
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.ContactName
: null,
DispatchTechPhone = w.PrimaryDispatch != null ? w.PrimaryDispatch.TechPhone : null,
WoTechPhone = w.TechPhone,
VendorPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.Phone
: null,
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
w.Trade,
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.DocStatus,
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
})
.ToListAsync();
return rows.Select(w =>
{
var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name;
var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber;
var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes;
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
return new WorkOrderBoardRawRow(
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
w.LocationName,
pocName,
pocPhone,
pocNotes,
w.LifecycleStatus,
w.LegacyStatus ?? w.Status,
w.AssignTo,
w.FirstName,
w.LastName,
w.Initials,
w.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
w.VendorId,
w.VendorName,
w.TechName,
techPhone,
w.DispatchApptDate,
w.Trade ?? w.Problem,
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.DocStatus,
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
isUnscheduled);
}).ToList();
}
}
}

View file

@ -0,0 +1,174 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardQueryFilters
{
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
public static IQueryable<WorkOrder> ApplyDispatcherFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? dispatchers,
bool myWorkOrders,
string? currentUserId)
{
if (myWorkOrders && !string.IsNullOrEmpty(currentUserId))
return query.Where(w => w.AssignTo == currentUserId);
if (dispatchers == null || dispatchers.Count == 0)
return query;
var ids = dispatchers
.Where(d => d != "__unassigned__")
.ToList();
var includeUnassigned = dispatchers.Contains("__unassigned__");
if (includeUnassigned && ids.Count == 0)
return query.Where(w => w.AssignTo == null || w.AssignTo == "");
if (includeUnassigned && ids.Count > 0)
{
return query.Where(w =>
w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
}
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
}
/// <summary>
/// FE sends <see cref="WorkOrderType.Other"/> (99) as the overdue/isPastDue sentinel — not a real type filter.
/// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types,
DateTime? utcNow = null)
{
if (types == null || types.Count == 0)
return query;
var today = (utcNow ?? DateTime.UtcNow).Date;
var includeOverdue = types.Contains(WorkOrderType.Other);
var typeFilters = types.Where(t => t != WorkOrderType.Other).ToList();
// Keep past-due predicate inline — EF cannot translate local method calls.
if (includeOverdue && typeFilters.Count == 0)
{
return query.Where(w =>
w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
}
if (includeOverdue)
{
return query.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)));
}
return query.Where(w => w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value));
}
public static IQueryable<WorkOrder> ApplyTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types)
=> ApplyTypeAndOverdueFilter(query, types);
public static IQueryable<WorkOrder> ApplySiteFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? sites)
{
if (sites == null || sites.Count == 0)
return query;
var normalized = sites
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.ToList();
if (normalized.Count == 0)
return query;
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode));
}
public static IQueryable<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses)
{
if (statuses == null || statuses.Count == 0)
return query;
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
}
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<DocStatus>? docStatuses)
{
if (docStatuses == null || docStatuses.Count == 0)
return query;
return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value));
}
public static IQueryable<WorkOrder> ApplyVendorFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<int>? vendorIds)
{
if (vendorIds == null || vendorIds.Count == 0)
return query;
return query.Where(w =>
w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId));
}
public static IQueryable<WorkOrder> ApplyPmTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? pmTypes)
{
if (pmTypes == null || pmTypes.Count == 0)
return query;
var normalized = pmTypes
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim().ToLower())
.ToList();
if (normalized.Count == 0)
return query;
return query.Where(w =>
normalized.Any(pm =>
(w.Trade ?? "").ToLower().Contains(pm)
|| (w.Problem ?? "").ToLower().Contains(pm)));
}
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
IQueryable<WorkOrder> query,
DateOnly dateFrom,
DateOnly dateTo)
{
var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date;
var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date;
return query.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date >= fromDate
&& w.ScheduledDate.Value.Date <= toDate)
|| (w.ScheduleWeekOnly == true
&& w.TargetWeek != null
&& w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo)
|| (w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled));
}
}
}

View file

@ -0,0 +1,133 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardSearchFilter
{
private static readonly Regex PartialDatePattern = new(
@"^\d{1,2}/\d{1,2}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
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<WorkOrder> Apply(IQueryable<WorkOrder> query, string? search)
{
var normalized = NormalizeSearch(search);
if (normalized == null)
return query;
var s = normalized.ToLower();
var today = DateTime.UtcNow.Date;
var exactDate = TryParseExactDate(normalized);
var hasExactDate = exactDate.HasValue;
var exactDateValue = exactDate?.Date ?? default;
var partialMonthDay = TryParsePartialMonthDay(normalized);
var hasPartialMonthDay = partialMonthDay.HasValue;
var partialMonth = partialMonthDay?.Month ?? 0;
var partialDay = partialMonthDay?.Day ?? 0;
// Keep predicates inline — EF Core cannot translate local method calls.
return query.Where(w =>
(w.SiteCode ?? "").ToLower().Contains(s)
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|| (w.WorkerOrderTitle ?? "").ToLower().Contains(s)
|| (w.Description ?? "").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.WorkOrderType == WorkOrderType.PO && "po".Contains(s))
|| (w.WorkOrderType == WorkOrderType.PM && "pm".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Emergency && "emergency".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|| ("overdue".Contains(s)
&& w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (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)))
|| (hasExactDate
&& ((w.ScheduledDate != null && w.ScheduledDate.Value.Date == exactDateValue)
|| (w.DueDate != null && w.DueDate.Value.Date == exactDateValue)))
|| (hasPartialMonthDay
&& ((w.ScheduledDate != null
&& w.ScheduledDate.Value.Month == partialMonth
&& w.ScheduledDate.Value.Day == partialDay)
|| (w.DueDate != null
&& w.DueDate.Value.Month == partialMonth
&& w.DueDate.Value.Day == partialDay))));
}
internal static DateTime? TryParseExactDate(string search)
{
if (PartialDatePattern.IsMatch(search))
return null;
var formats = new[]
{
"yyyy-MM-dd",
"MM/dd/yyyy",
"M/d/yyyy",
"MM/dd/yy",
"M/d/yy"
};
if (DateTime.TryParseExact(search, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
return parsed.Date;
if (DateTime.TryParse(search, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed))
return parsed.Date;
return null;
}
internal static (int Month, int Day)? TryParsePartialMonthDay(string search)
{
if (!PartialDatePattern.IsMatch(search))
return null;
var parts = search.Split('/');
if (parts.Length != 2)
return null;
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var month))
return null;
if (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var day))
return null;
if (month is < 1 or > 12 || day is < 1 or > 31)
return null;
return (month, day);
}
}
}

View file

@ -0,0 +1,103 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
{
private readonly ApplicationDbContext _context;
public CompletionDocTemplateDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
return await query
.OrderBy(t => t.Name)
.Select(MapRow)
.ToListAsync();
}
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
{
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
{
if (!string.IsNullOrWhiteSpace(trade))
{
var byTrade = await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true && t.ServiceKey == trade)
.OrderBy(t => t.WorkOrderType == workOrderType ? 0 : 1)
.ThenBy(t => t.Name)
.ThenBy(t => t.Id)
.Select(MapRow)
.FirstOrDefaultAsync();
if (byTrade != null)
return byTrade;
}
if (!workOrderType.HasValue)
return null;
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true && t.WorkOrderType == workOrderType)
.OrderBy(t => t.Name)
.ThenBy(t => t.Id)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
{
template.CreatedDate = DateTime.UtcNow;
await _context.CompletionDocTemplates.AddAsync(template);
await _context.SaveChangesAsync();
return template;
}
public async Task UpdateAsync(CompletionDocTemplate template)
{
template.LastModificationTime = DateTime.UtcNow;
_context.CompletionDocTemplates.Update(template);
await _context.SaveChangesAsync();
}
public async Task<bool> DeleteAsync(int id)
{
var entity = await _context.CompletionDocTemplates.FindAsync(id);
if (entity == null)
return false;
entity.IsDeleted = true;
entity.DeletionTime = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
private static readonly System.Linq.Expressions.Expression<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
}
}

View file

@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount);
}
public async Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null)
{
var query = _context.Locations.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.ToLower();
query = query.Where(l =>
(l.Name ?? "").ToLower().Contains(s) ||
(l.City ?? "").ToLower().Contains(s));
}
return await query
.OrderBy(l => l.Name)
.Select(l => new SiteOptionRow(l.Id, l.Name, l.City, l.State))
.ToListAsync();
}
public async Task<Locations> AddAsync(Locations location)
{
location.CreatedDate = DateTime.Now;

View file

@ -33,5 +33,27 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Users.AnyAsync(u => u.Id == id);
}
public async Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids)
{
var idList = ids
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.Ordinal)
.ToList();
if (idList.Count == 0)
return new Dictionary<string, string>(StringComparer.Ordinal);
var users = await _context.Users
.AsNoTracking()
.Where(u => idList.Contains(u.Id))
.Select(u => new { u.Id, u.FirstName, u.LastName })
.ToListAsync();
return users.ToDictionary(
u => u.Id,
u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(),
StringComparer.Ordinal);
}
}
}

View file

@ -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<WorkOrderAdvancedSearchResult> 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 * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
}
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> 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)
};
}
}
}

View file

@ -0,0 +1,96 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderBoardDataService : IWorkOrderBoardDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderBoardDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query)
{
var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue);
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
var scheduledBase = baseQuery.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date >= weekStartDate.Date
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
|| (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart));
var unscheduledBase = baseQuery.Where(w =>
w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
var scheduledTotal = await scheduledBase.CountAsync();
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
if (normalizedSearch != null)
{
scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch);
unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch);
}
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber),
isUnscheduled: false);
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
unscheduledBase.OrderBy(w => w.InternalWONumber),
isUnscheduled: true);
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
}
public async Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync()
{
return await _context.Users
.AsNoTracking()
.Where(u => u.IsDeleted != true)
.OrderBy(u => u.FirstName)
.ThenBy(u => u.LastName)
.Select(u => new DispatcherLookupRow(
u.Id,
u.FirstName ?? "",
u.LastName,
u.Initials,
u.Color))
.ToListAsync();
}
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId)
{
var query = _context.workOrders
.AsNoTracking()
.Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
return rows.FirstOrDefault();
}
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
{
return await _context.workOrders
.AsNoTracking()
.AnyAsync(w =>
w.Id != excludeWorkOrderId
&& w.istemplate != true
&& (w.IsDeleted != true || w.IsDeleted == null)
&& w.InternalWONumber == normalizedWoNumber);
}
}
}

View file

@ -0,0 +1,95 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderDetailDataService : IWorkOrderDetailDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderDetailDataService(ApplicationDbContext context)
{
_context = context;
}
public Task<bool> ExistsAsync(int workOrderId)
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.AnyAsync(w => w.Id == workOrderId);
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
{
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.Where(w => w.Id == workOrderId)
.Select(w => new WorkOrderDetailExtendedFields(
w.Description,
w.Trade,
w.Problem,
w.SubTrade,
w.OriginalWeek,
w.OriginalDate,
w.CompletedDate,
w.SignOffName,
w.SignOffAttachment,
w.SignOffSignature,
w.ExtraServices,
w.ServiceNotes,
w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null
? w.PrimaryDispatch.VendorNotes
: w.VendorNotes))
.FirstOrDefaultAsync();
}
public async Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId)
{
return await _context.Comments
.AsNoTracking()
.Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true)
.OrderByDescending(c => c.CreatedDate)
.ToListAsync();
}
public async Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null)
{
var query = _context.WorkOrderAuditLogs
.AsNoTracking()
.Where(a => a.WorkOrderId == workOrderId)
.OrderByDescending(a => a.CreatedAt);
if (limit.HasValue)
return await query.Take(limit.Value).ToListAsync();
return await query.ToListAsync();
}
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
{
return await _context.workOrderAttachments
.AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
.OrderByDescending(a => a.CreatedDate)
.ToListAsync();
}
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
{
return await _context.DispatchSignoffs
.AsNoTracking()
.Where(s => s.Dispatch != null && s.Dispatch.WorkOrderId == workOrderId && s.IsDeleted != true)
.OrderByDescending(s => s.SignedAt)
.Select(s => new DispatchSignoffRow(
s.DispatchId,
s.SignoffType,
s.Name,
s.SignedAt))
.ToListAsync();
}
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
{
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.FirstOrDefaultAsync(w => w.Id == workOrderId);
}
}
}

View file

@ -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<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
DateOnly sourceWeekStart,
DateOnly sourceWeekEnd,
int batchSize = 500,
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)
.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)
.Take(batchSize)
.Select(w => w.Id)
.ToListAsync(cancellationToken);
}
public async Task<WeekRolledProcessOutcome> 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<PastDueRefreshResult> 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
};
}
}
}

View file

@ -2,6 +2,8 @@ using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public record SiteOptionRow(int Id, string? Code, string? City, string? State);
public interface ILocationDataService
{
Task<Locations?> GetByIdAsync(int id);
@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces
// For addressbook endpoint - returns simplified DTO
Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null);
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
Task<Locations> AddAsync(Locations location);
Task UpdateAsync(Locations location);
Task DeleteAsync(int id);

View file

@ -8,5 +8,6 @@ namespace SeaHaven.DataServices.Interfaces
Task<IEnumerable<ApplicationUser>> GetAllAsync();
Task<ApplicationUser?> GetByEmailAsync(string email);
Task<bool> ExistsAsync(string id);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderAdvancedSearchDataService
{
Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query);
}
}

View file

@ -0,0 +1,12 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderBoardDataService
{
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId);
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
}
}

View file

@ -0,0 +1,27 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderDetailDataService
{
Task<bool> ExistsAsync(int workOrderId);
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
}
public interface ICompletionDocTemplateDataService
{
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
Task UpdateAsync(CompletionDocTemplate template);
Task<bool> DeleteAsync(int id);
}
}

View file

@ -0,0 +1,37 @@
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<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
DateOnly sourceWeekStart,
DateOnly sourceWeekEnd,
int batchSize = 500,
CancellationToken cancellationToken = default);
Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
int workOrderId,
DateOnly sourceWeekStart,
string correlationId,
CancellationToken cancellationToken = default);
Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
DateTime utcNow,
int batchSize = 500,
CancellationToken cancellationToken = default);
}
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.DataServices.Interfaces
{
public record PrimaryDispatchProjection(
int WorkOrderId,
int? DispatchId,
string? VendorName,
DateTime? ApptDate,
string? DispatchStatus);
}

View file

@ -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<string>? Sites,
IReadOnlyList<WorkOrderType>? Types,
IReadOnlyList<string>? Dispatchers,
IReadOnlyList<LifecycleStatus>? Statuses,
IReadOnlyList<string>? PmTypes,
IReadOnlyList<int>? VendorIds,
IReadOnlyList<DocStatus>? DocStatuses,
bool MyWorkOrders,
string? CurrentUserId,
int Page,
int PageSize,
string SortBy,
string SortDir);
public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows,
int TotalCount);
}

View file

@ -0,0 +1,65 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record WorkOrderBoardQuery(
DateOnly WeekStart,
DateOnly WeekEnd,
IReadOnlyList<string>? Dispatchers,
bool MyWorkOrders,
IReadOnlyList<WorkOrderType>? Types,
string? Search,
string? CurrentUserId);
public record WorkOrderBoardRawRow(
int Id,
string? InternalWONumber,
int RescheduleCount,
int CarriedOver,
WorkOrderType? WorkOrderType,
string? SiteCode,
string? LocationName,
string? PocName,
string? PocPhone,
string? PocNotes,
LifecycleStatus? LifecycleStatus,
string? LegacyStatus,
string? AssignTo,
string? DispatcherFirstName,
string? DispatcherLastName,
string? Initials,
string? Color,
DateTime? DueDate,
DateTime? ScheduledDate,
DateTime? ScheduledStart,
DateTime? ScheduledEnd,
DateOnly? TargetWeek,
bool? ScheduleWeekOnly,
int? VendorId,
string? VendorName,
string? TechName,
string? TechPhone,
DateTime? DispatchApptDate,
string? Trade,
string? Problem,
string? ServiceNotes,
string? ExtraServices,
DocStatus? DocStatus,
string? FlagColor,
int? PrimaryDispatchId,
byte[]? RowVersion,
byte[]? DispatchRowVersion,
bool IsUnscheduled);
public record WorkOrderBoardQueryResult(
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
IReadOnlyList<WorkOrderBoardRawRow> UnscheduledRows,
int ScheduledTotalBeforeSearch);
public record DispatcherLookupRow(
string Id,
string FirstName,
string? LastName,
string? Initials,
string? Color);
}

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record WorkOrderDetailExtendedFields(
string? Description,
string? Trade,
string? Problem,
string? SubTrade,
DateOnly? OriginalWeek,
DateOnly? OriginalDate,
DateTime? CompletedDate,
string? SignOffName,
string? SignOffAttachment,
string? SignOffSignature,
string? ExtraServices,
string? ServiceNotes,
string? VendorNotes);
public record DispatchSignoffRow(
int DispatchId,
string? SignoffType,
string? Name,
DateTime? SignedAt);
public record CompletionDocTemplateRow(
int Id,
string Name,
string ServiceKey,
WorkOrderType? WorkOrderType,
string TemplateUrl,
bool IsActive);
}

View file

@ -8,8 +8,9 @@ public class PagedResultTests
{
[Theory]
[InlineData(0, 10, 0)]
[InlineData(1, 10, 1)]
[InlineData(10, 10, 1)]
[InlineData(25, 10, 3)]
[InlineData(11, 10, 2)]
[InlineData(100, 25, 4)]
public void TotalPages_IsCeilingOfCountOverPageSize(int totalCount, int pageSize, int expectedPages)
{
@ -19,16 +20,32 @@ public class PagedResultTests
}
[Fact]
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward()
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward_OneBased()
{
new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse();
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue();
}
[Fact]
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage()
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage_OneBased()
{
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue();
new PagedResult<string> { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse();
}
[Fact]
public void HasPrevious_And_HasNext_ZeroBased_WhenPageBaseIsZero()
{
var first = new PagedResult<string> { Page = 0, PageSize = 10, TotalCount = 30, PageBase = 0 };
first.HasPrevious.Should().BeFalse();
first.HasNext.Should().BeTrue();
var middle = new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30, PageBase = 0 };
middle.HasPrevious.Should().BeTrue();
middle.HasNext.Should().BeTrue();
var last = new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30, PageBase = 0 };
last.HasPrevious.Should().BeTrue();
last.HasNext.Should().BeFalse();
}
}

View file

@ -0,0 +1,8 @@
namespace SeaHaven.Services.Board
{
public static class WorkOrderBoardConstants
{
public const int MaxWindowDays = 90;
public const int ClientSideThreshold = 300;
}
}

View file

@ -0,0 +1,26 @@
namespace SeaHaven.Services.Constants
{
public static class WorkOrderFieldNames
{
public const string LifecycleStatus = nameof(LifecycleStatus);
public const string AssignTo = nameof(AssignTo);
public const string ScheduledDate = nameof(ScheduledDate);
public const string ScheduledEnd = nameof(ScheduledEnd);
public const string TargetWeek = nameof(TargetWeek);
public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);
public const string DueDate = nameof(DueDate);
public const string Description = nameof(Description);
public const string WorkerOrderTitle = nameof(WorkerOrderTitle);
public const string SiteCode = nameof(SiteCode);
public const string Building = nameof(Building);
public const string LocationId = nameof(LocationId);
public const string WorkOrderType = nameof(WorkOrderType);
public const string Service = nameof(Service);
public const string DocStatus = nameof(DocStatus);
public const string Trade = nameof(Trade);
public const string Problem = nameof(Problem);
public const string Priority = nameof(Priority);
public const string InternalWONumber = nameof(InternalWONumber);
public const string Status = nameof(Status);
}
}

View file

@ -48,8 +48,17 @@ namespace SeaHaven.Services.DTOs
public int TotalCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => Page > 1;
public bool HasNext => Page < TotalPages;
/// <summary>
/// Page numbering base for <see cref="HasPrevious"/> / <see cref="HasNext"/>.
/// Default <c>1</c> (classic APIs). Work-order advanced search sets <c>0</c>.
/// </summary>
public int PageBase { get; set; } = 1;
public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => PageBase <= 0 ? Page > 0 : Page > 1;
public bool HasNext => PageBase <= 0 ? Page + 1 < TotalPages : Page < TotalPages;
}
}

View file

@ -36,4 +36,12 @@ namespace SeaHaven.Services.DTOs
public string? Zipcode { get; set; }
public int? AccountId { get; set; }
}
public class SiteOptionDTO
{
public int Id { get; set; }
public string? Code { get; set; }
public string? City { get; set; }
public string? State { get; set; }
}
}

View file

@ -0,0 +1,101 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
namespace SeaHaven.Services.DTOs
{
public class WorkOrderBoardQueryDto
{
public DateOnly WeekStart { get; set; }
public DateOnly? WeekEnd { get; set; }
public List<string>? Dispatchers { get; set; }
public bool MyWorkOrders { get; set; }
public List<WorkOrderType>? Types { get; set; }
public string? Search { get; set; }
}
public class WorkOrderBoardCountsDto
{
public int Returned { get; set; }
public int Total { get; set; }
}
public class WorkOrderBoardRowDto
{
public int Id { get; set; }
public string? WoNumber { get; set; }
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
public bool IsPastDue { get; set; }
public string? SiteCode { get; set; }
public string? LocationName { get; set; }
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
public LifecycleStatus? LifecycleStatus { get; set; }
public string? LifecycleStatusLabel { get; set; }
public string? LegacyStatus { get; set; }
public string? DispatcherId { get; set; }
public string? DispatcherName { get; set; }
public string? Initials { get; set; }
public string? Color { get; set; }
public DateTime? DueDate { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateOnly? TargetWeek { get; set; }
public bool? ScheduleWeekOnly { get; set; }
public string? DayGroup { get; set; }
public int? VendorId { get; set; }
public string? VendorName { get; set; }
public string? TechName { get; set; }
public string? TechPhone { get; set; }
public DateTime? ApptDate { get; set; }
public string? ApptTime { get; set; }
public string? Pm { get; set; }
public string? ServiceNotes { get; set; }
public List<string>? ExtraServices { get; set; }
public DocStatus? DocStatus { get; set; }
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
public string? FlagColor { get; set; }
public int? PrimaryDispatchId { get; set; }
public byte[]? RowVersion { get; set; }
public byte[]? DispatchRowVersion { get; set; }
}
public class WorkOrderBoardPatchRequestDto
{
public string Field { get; set; } = "";
public string? Value { get; set; }
public string? WorkOrderVersion { get; set; }
public string? DispatchVersion { get; set; }
public int? PrimaryDispatchId { get; set; }
}
public class WorkOrderBoardConflictDto
{
public string Message { get; set; } = "The record was modified by another user. Refresh and retry.";
public WorkOrderBoardRowDto? CurrentState { get; set; }
}
public class WorkOrderBoardValidationErrorDto
{
public string Code { get; set; } = "";
public string Message { get; set; } = "";
}
public class WorkOrderBoardResponseDto
{
public DateOnly WeekStart { get; set; }
public DateOnly WeekEnd { get; set; }
public WorkOrderBoardCountsDto Counts { get; set; } = new();
public List<WorkOrderBoardRowDto> Unscheduled { get; set; } = new();
public List<WorkOrderBoardRowDto> Scheduled { get; set; } = new();
}
public class DispatcherLookupDto
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string? Initials { get; set; }
public string? Color { get; set; }
}
}

View file

@ -0,0 +1,63 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
namespace SeaHaven.Services.DTOs
{
/// <summary>
/// Payload for creating a board work order (SHOC create wizard).
/// FE follow-up: extend WorkOrderBoardCreatePayload + mapper with PrimaryService,
/// ExtraServices, ServiceNotes, PocName, PocPhone, VendorNotes, TechPhone; stop embedding
/// POC/vendor/service notes in description (pocNotes already exists). Prefer structured
/// fields; keep Trade+Description temporarily for legacy clients.
/// </summary>
public class WorkOrderBoardCreateRequestDto
{
public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; }
public string? SiteCode { get; set; }
public string? Description { get; set; }
/// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary>
public string? Trade { get; set; }
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
public string? PrimaryService { get; set; }
public List<string>? ExtraServices { get; set; }
public string? ServiceNotes { get; set; }
public int? LocationId { get; set; }
public DateTime? DueDate { get; set; }
public string? AssignTo { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateOnly? TargetWeek { get; set; }
public bool? ScheduleWeekOnly { get; set; }
public DocStatus? DocStatus { get; set; }
public int? VendorId { get; set; }
public DateTime? ApptDate { get; set; }
public string? ApptTime { get; set; }
public int? PocContactId { get; set; }
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
public string? TechPhone { get; set; }
public string? VendorNotes { get; set; }
}
/// <summary>Advanced search / filter query for the board.</summary>
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<string>? Sites { get; set; }
public List<WorkOrderType>? Types { get; set; }
public List<string>? Dispatchers { get; set; }
public List<LifecycleStatus>? Statuses { get; set; }
public List<string>? PmTypes { get; set; }
public List<int>? VendorIds { get; set; }
public List<DocStatus>? DocStatuses { get; set; }
public bool MyWorkOrders { get; set; }
public string? SortBy { get; set; }
public string? SortDir { get; set; }
}
}

View file

@ -0,0 +1,106 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs
{
public class WorkOrderDetailDto
{
public WorkOrderDetailInfoDto Info { get; set; } = new();
public WorkOrderCompletionDto Completion { get; set; } = new();
public List<WorkOrderCommentDto> Comments { get; set; } = new();
public List<WorkOrderAuditEntryDto> Audit { get; set; } = new();
public List<WorkOrderMediaFileDto> Media { get; set; } = new();
}
public class WorkOrderDetailInfoDto : WorkOrderBoardRowDto
{
public string? Description { get; set; }
public string? Trade { get; set; }
public string? Problem { get; set; }
public string? SubTrade { get; set; }
public DateOnly? OriginalWeek { get; set; }
public DateOnly? OriginalDate { get; set; }
public DateTime? CompletedDate { get; set; }
public string? VendorNotes { get; set; }
}
public class CompletionDocTemplateDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; }
}
public class CompletionDocTemplateCreateDto
{
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
}
public class WorkOrderCompletionDto
{
public DocStatus? DocStatus { get; set; }
public CompletionDocTemplateDto? Template { get; set; }
public string? SignOffName { get; set; }
public string? SignOffAttachment { get; set; }
public string? SignOffSignature { get; set; }
public List<DispatchSignoffDto> DispatchSignoffs { get; set; } = new();
}
public class DispatchSignoffDto
{
public int DispatchId { get; set; }
public string? SignoffType { get; set; }
public string? Name { get; set; }
public string? SignedAt { get; set; }
}
public class WorkOrderCommentDto
{
public int Id { get; set; }
public string? AuthorId { get; set; }
public string? AuthorName { get; set; }
public string Text { get; set; } = "";
public string Time { get; set; } = "";
public string? Documents { get; set; }
}
public class WorkOrderCommentCreateDto
{
public string Text { get; set; } = "";
}
public class WorkOrderAuditEntryDto
{
public string Type { get; set; } = "manual";
public string? DispatcherId { get; set; }
public string Action { get; set; } = "";
public string? FieldName { get; set; }
public string OldValue { get; set; } = "";
public string NewValue { get; set; } = "";
public string Time { get; set; } = "";
public int? DispatchId { get; set; }
}
public class WorkOrderMediaFileDto
{
public int Id { get; set; }
public WorkOrderMediaCategory Category { get; set; }
public string Url { get; set; } = "";
public string? FileName { get; set; }
public string? UploadedAt { get; set; }
public bool IsLegacy { get; set; }
}
public class WorkOrderCompletionDocUploadDto
{
public string? SignOffName { get; set; }
public string? SignOffSignature { get; set; }
public string? WorkOrderVersion { get; set; }
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,54 @@
namespace SeaHaven.Services.DTOs
{
public sealed class WorkOrderIngestPayloadDto
{
public required string ExternalWorkOrderId { get; set; }
public string? Description { get; set; }
public string? WoStatus { get; set; }
public string? Severity { get; set; }
public string? Customer { get; set; }
public string? SiteCode { get; set; }
public string? Building { get; set; }
public string? Address { get; set; }
public DateTime? DueDate { get; set; }
public DateTime? DateReported { get; set; }
public DateTime? ScheduledStart { get; set; }
public string? SourceEmailS3Key { get; set; }
public DateTime? CreatedAt { get; set; }
}
public sealed class WorkOrderIngestBatchRequestDto
{
public List<WorkOrderIngestPayloadDto>? Items { get; set; }
}
public sealed class WorkOrderIngestItemResultDto
{
public required string ExternalWorkOrderId { get; set; }
public int WorkOrderId { get; set; }
public bool Created { get; set; }
}
public sealed class WorkOrderIngestBatchResultDto
{
public int Created { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public List<WorkOrderIngestItemResultDto> Results { get; set; } = new();
}
public sealed class WorkOrderOpsHealthDto
{
public DateTime? LastWeekRolledRunUtc { get; set; }
public DateTime? LastPastDueCacheRunUtc { get; set; }
public string? LastWeekRolledError { get; set; }
public string? LastPastDueCacheError { get; set; }
public int SyncRejectedLast24h { get; set; }
public int FieldLockCount { get; set; }
public bool SyncEnabled { get; set; }
public bool IngestEnabled { get; set; }
public bool LegacyDeprecationEnabled { get; set; }
public string? LegacySunsetDate { get; set; }
public DateTime CheckedAtUtc { get; set; }
}
}

View file

@ -0,0 +1,25 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Exceptions
{
public class WorkOrderBoardValidationException : Exception
{
public WorkOrderBoardValidationException(string code, string message) : base(message)
{
Code = code;
}
public string Code { get; }
}
public class WorkOrderBoardConcurrencyException : Exception
{
public WorkOrderBoardConcurrencyException(WorkOrderBoardRowDto? currentState)
: base("The record was modified by another user. Refresh and retry.")
{
CurrentState = currentState;
}
public WorkOrderBoardRowDto? CurrentState { get; }
}
}

View file

@ -0,0 +1,85 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class LifecycleStatusMapper
{
public static LifecycleStatus? FromLegacyStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status)) return null;
return status.Trim().ToLowerInvariant() switch
{
"open" or "incomplete" => LifecycleStatus.Incomplete,
"pending" or "onhold" or "on hold" => LifecycleStatus.Pending,
"scheduled" => LifecycleStatus.Scheduled,
"en route" or "enroute" => LifecycleStatus.EnRoute,
"on site" or "onsite" => LifecycleStatus.OnSite,
"inprogress" or "in progress" => LifecycleStatus.InProgress,
"completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed,
"rescheduled" => LifecycleStatus.Rescheduled,
"cancelled" or "canceled" => LifecycleStatus.Canceled,
"pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote,
_ => null
};
}
public static LifecycleStatus? ParseLifecycleStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (TryParseFeLabel(value, out var fromLabel))
return fromLabel;
if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed))
return parsed;
return FromLegacyStatus(value);
}
public static bool TryParseFeLabel(string? label, out LifecycleStatus status)
{
status = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"incomplete" => Set(LifecycleStatus.Incomplete, out status),
"pending" => Set(LifecycleStatus.Pending, out status),
"scheduled" => Set(LifecycleStatus.Scheduled, out status),
"en route" => Set(LifecycleStatus.EnRoute, out status),
"on site" => Set(LifecycleStatus.OnSite, out status),
"in progress" => Set(LifecycleStatus.InProgress, out status),
"completed" => Set(LifecycleStatus.Completed, out status),
"rescheduled" => Set(LifecycleStatus.Rescheduled, out status),
"canceled" => Set(LifecycleStatus.Canceled, out status),
"pending quote" => Set(LifecycleStatus.PendingQuote, out status),
_ => false
};
}
public static string ToFeLabel(LifecycleStatus status) => status switch
{
LifecycleStatus.Incomplete => "Incomplete",
LifecycleStatus.Pending => "Pending",
LifecycleStatus.Scheduled => "Scheduled",
LifecycleStatus.EnRoute => "En Route",
LifecycleStatus.OnSite => "On Site",
LifecycleStatus.InProgress => "In Progress",
LifecycleStatus.Completed => "Completed",
LifecycleStatus.Rescheduled => "Rescheduled",
LifecycleStatus.Canceled => "Canceled",
LifecycleStatus.PendingQuote => "Pending Quote",
_ => status.ToString()
};
public static bool IsTerminal(LifecycleStatus? status)
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
private static bool Set(LifecycleStatus value, out LifecycleStatus status)
{
status = value;
return true;
}
}
}

View file

@ -0,0 +1,87 @@
using Data.SeaHavenIndustries;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderAuditProjection
{
private const string UnassignedLabel = "Unassigned";
public static WorkOrderAuditEntryDto ToDto(
WorkOrderAuditLog log,
IReadOnlyDictionary<string, string>? assignToDisplayNames = null)
{
var oldValue = log.OldValue ?? "";
var newValue = log.NewValue ?? "";
if (IsAssignToField(log.FieldName))
{
oldValue = ResolveAssignToDisplay(oldValue, assignToDisplayNames);
newValue = ResolveAssignToDisplay(newValue, assignToDisplayNames);
}
return new WorkOrderAuditEntryDto
{
Type = MapEventType(log.EventType),
DispatcherId = log.UserId,
Action = log.Action ?? "",
FieldName = log.FieldName,
OldValue = oldValue,
NewValue = newValue,
Time = log.CreatedAt.ToString("o"),
DispatchId = log.DispatchId
};
}
public static List<WorkOrderAuditEntryDto> ToDtos(
IEnumerable<WorkOrderAuditLog> logs,
IReadOnlyDictionary<string, string>? assignToDisplayNames = null)
=> logs.Select(log => ToDto(log, assignToDisplayNames)).ToList();
public static IEnumerable<string> CollectAssignToUserIds(IEnumerable<WorkOrderAuditLog> logs)
{
return logs
.Where(l => IsAssignToField(l.FieldName))
.SelectMany(l => new[] { l.OldValue, l.NewValue })
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v!)
.Distinct(StringComparer.Ordinal);
}
internal static bool IsAssignToField(string? fieldName)
=> string.Equals(fieldName, "AssignTo", StringComparison.OrdinalIgnoreCase)
|| string.Equals(fieldName, "AssignedTo", StringComparison.OrdinalIgnoreCase);
private static string ResolveAssignToDisplay(
string value,
IReadOnlyDictionary<string, string>? assignToDisplayNames)
{
if (string.IsNullOrWhiteSpace(value))
return UnassignedLabel;
if (assignToDisplayNames != null
&& assignToDisplayNames.TryGetValue(value, out var name)
&& !string.IsNullOrWhiteSpace(name))
{
return name;
}
return value;
}
private static string MapEventType(string? eventType)
{
if (string.IsNullOrWhiteSpace(eventType))
return "manual";
return eventType.ToLowerInvariant() switch
{
"manual" => "manual",
"system" => "system",
"sync" => "sync",
"vendor" => "vendor",
_ => eventType.ToLowerInvariant()
};
}
}
}

View file

@ -0,0 +1,57 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardApptTimeParser
{
public static bool TryParse(string? value, out TimeSpan? start, out TimeSpan? end, out string? error)
{
start = null;
end = null;
error = null;
if (string.IsNullOrWhiteSpace(value))
return true;
var trimmed = value.Trim();
var parts = trimmed.Split(new[] { '–', '—', '-' })
.Select(p => p.Trim())
.Where(p => !string.IsNullOrEmpty(p))
.ToArray();
if (parts.Length == 1)
{
if (!TimeSpan.TryParse(parts[0], out var single))
{
error = "Invalid appointment time format.";
return false;
}
start = single;
return true;
}
if (parts.Length == 2)
{
if (!TimeSpan.TryParse(parts[0], out var startTime) || !TimeSpan.TryParse(parts[1], out var endTime))
{
error = "Invalid appointment time range format.";
return false;
}
start = startTime;
end = endTime;
return true;
}
error = "Invalid appointment time format.";
return false;
}
public static DateTime? CombineDateAndTime(DateTime? date, TimeSpan? time)
{
if (!date.HasValue || !time.HasValue)
return date;
return date.Value.Date.Add(time.Value);
}
}
}

View file

@ -0,0 +1,132 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Exceptions;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardFieldMutations
{
public static List<BoardFieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField = "AssignTo")
{
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
var old = workOrder.AssignTo;
var changes = new List<BoardFieldChange>();
if (old != normalized)
{
workOrder.AssignTo = normalized;
changes.Add(BoardFieldChange.AssignmentChanged(auditField, old, normalized));
}
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
public static List<BoardFieldChange> ApplyScheduledDate(
WorkOrder workOrder,
DateTime? parsed,
string auditField = "ScheduledDate",
bool incrementRescheduleOnChange = true)
{
var previousDate = workOrder.ScheduledDate;
var changes = new List<BoardFieldChange>();
var oldFormatted = FormatDate(previousDate);
var newFormatted = FormatDate(parsed);
if (oldFormatted == newFormatted)
return changes;
workOrder.ScheduledDate = parsed;
changes.Add(BoardFieldChange.Changed(auditField, oldFormatted, newFormatted));
if (!workOrder.OriginalDate.HasValue && parsed.HasValue)
{
workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value);
var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed);
if (originalWeek.HasValue)
workOrder.OriginalWeek = originalWeek;
}
if (incrementRescheduleOnChange && WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed))
{
var oldCount = workOrder.RescheduleCount.ToString();
workOrder.RescheduleCount++;
changes.Add(BoardFieldChange.Changed(
WorkOrderBoardFieldNames.RescheduleCount,
oldCount,
workOrder.RescheduleCount.ToString()));
}
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<BoardFieldChange>();
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo))
return changes;
var old = workOrder.LifecycleStatus?.ToString();
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
return changes;
}
public static BoardFieldChange 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.");
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
var oldStart = FormatDateTime(workOrder.ScheduledStart);
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
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);
if (oldCombined == newCombined)
return BoardFieldChange.Unchanged("ApptTime", dispatch.Id);
return BoardFieldChange.Changed("ApptTime", oldCombined, newCombined, dispatch.Id);
}
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
{
workOrder.LifecycleStatus = LifecycleStatus.Incomplete;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Incomplete);
}
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)
=> start == null && end == null ? null : $"{start}|{end}";
public sealed record BoardFieldChange(
string FieldName,
string? OldValue,
string? NewValue,
AuditActionType Action,
int? DispatchId,
bool HasChanged)
{
public static BoardFieldChange Unchanged(string fieldName, int? dispatchId = null)
=> new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false);
public static BoardFieldChange Changed(string fieldName, string? oldValue, string? newValue, int? dispatchId = null)
=> new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true);
public static BoardFieldChange StatusChanged(string fieldName, string? oldValue, string? newValue)
=> new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true);
public static BoardFieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue)
=> new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true);
}
}
}

View file

@ -0,0 +1,94 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardFieldNames
{
public const string WoNumber = "woNumber";
public const string WorkOrderType = "workOrderType";
public const string SiteCode = "siteCode";
public const string LifecycleStatus = "lifecycleStatus";
public const string AssignTo = "assignTo";
public const string DueDate = "dueDate";
public const string ScheduledDate = "scheduledDate";
public const string TargetWeek = "targetWeek";
public const string ScheduleWeekOnly = "scheduleWeekOnly";
public const string VendorId = "vendorId";
public const string ApptDate = "apptDate";
public const string ApptTime = "apptTime";
public const string DocStatus = "docStatus";
public const string Pm = "pm";
public const string ServiceNotes = "serviceNotes";
public const string ExtraServices = "extraServices";
public const string FlagColor = "flagColor";
public const string RescheduleCount = "RescheduleCount";
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
{
VendorId,
ApptDate,
ApptTime
};
private static readonly HashSet<string> AllEditable = new(StringComparer.OrdinalIgnoreCase)
{
WoNumber,
WorkOrderType,
SiteCode,
LifecycleStatus,
AssignTo,
DueDate,
ScheduledDate,
TargetWeek,
ScheduleWeekOnly,
VendorId,
ApptDate,
ApptTime,
DocStatus,
Pm,
ServiceNotes,
ExtraServices,
FlagColor
};
public static bool IsEditable(string field) => Canonicalize(field) != null;
public static bool IsDispatchField(string field)
{
var canonical = Canonicalize(field);
return canonical != null && DispatchFields.Contains(canonical);
}
public static string? Canonicalize(string field)
{
if (string.IsNullOrWhiteSpace(field))
return null;
return AllEditable.FirstOrDefault(f => string.Equals(f, field, StringComparison.OrdinalIgnoreCase));
}
public static string ToAuditFieldName(string field)
{
var canonical = Canonicalize(field) ?? field;
return canonical switch
{
WoNumber => "InternalWONumber",
WorkOrderType => "WorkOrderType",
SiteCode => "SiteCode",
LifecycleStatus => "LifecycleStatus",
AssignTo => "AssignTo",
DueDate => "DueDate",
ScheduledDate => "ScheduledDate",
TargetWeek => "TargetWeek",
ScheduleWeekOnly => "ScheduleWeekOnly",
VendorId => "VendorId",
ApptDate => "ApptDate",
ApptTime => "ApptTime",
DocStatus => "DocStatus",
Pm => "Trade",
ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices",
FlagColor => "FlagColor",
_ => canonical
};
}
}
}

View file

@ -0,0 +1,42 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardMutationRules
{
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses = new()
{
LifecycleStatus.Canceled,
LifecycleStatus.Completed
};
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);
public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue)
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
public static bool ShouldAutoSchedule(
LifecycleStatus? status,
DateTime? scheduledDate,
string? assignTo)
=> status == LifecycleStatus.Incomplete
&& scheduledDate.HasValue
&& !string.IsNullOrWhiteSpace(assignTo);
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
=> previousDate.HasValue
&& newDate.HasValue
&& previousDate.Value.Date != newDate.Value.Date;
public static DateOnly? ComputeOriginalWeek(DateTime? scheduledDate)
=> scheduledDate.HasValue
? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value))
: null;
private static DateTime GetWeekStartMonday(DateTime date)
{
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
return date.Date.AddDays(-diff);
}
}
}

View file

@ -0,0 +1,29 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardSortFields
{
public static readonly IReadOnlySet<string> Allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"scheduledDate",
"woNumber",
"dueDate"
};
public static string NormalizeOrThrow(string? sortBy)
{
var field = (sortBy ?? "scheduledDate").Trim();
if (!Allowed.Contains(field))
{
throw new ArgumentException(
"sortBy must be one of: scheduledDate, woNumber, dueDate.");
}
return field.ToLowerInvariant() switch
{
"wonumber" => "woNumber",
"duedate" => "dueDate",
_ => "scheduledDate"
};
}
}
}

View file

@ -0,0 +1,52 @@
using Data.SeaHavenIndustries;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderCommentProjection
{
public static WorkOrderCommentDto ToDto(
Comments comment,
IReadOnlyDictionary<string, string>? displayNames = null)
{
return new WorkOrderCommentDto
{
Id = comment.Id,
AuthorId = comment.UserId,
AuthorName = ResolveAuthorName(comment, displayNames),
Text = comment.Commenttext ?? "",
Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"),
Documents = comment.Documents
};
}
public static List<WorkOrderCommentDto> ToDtos(
IEnumerable<Comments> comments,
IReadOnlyDictionary<string, string>? displayNames = null)
=> comments.Select(c => ToDto(c, displayNames)).ToList();
public static IEnumerable<string> CollectAuthorUserIds(IEnumerable<Comments> comments)
=> comments
.Where(c => !string.IsNullOrWhiteSpace(c.UserId))
.Select(c => c.UserId!)
.Distinct(StringComparer.Ordinal);
private static string? ResolveAuthorName(
Comments comment,
IReadOnlyDictionary<string, string>? displayNames)
{
if (!string.IsNullOrWhiteSpace(comment.UserId)
&& displayNames != null
&& displayNames.TryGetValue(comment.UserId, out var name)
&& !string.IsNullOrWhiteSpace(name))
{
return name;
}
if (!string.IsNullOrWhiteSpace(comment.Commenter))
return comment.Commenter.Trim();
return null;
}
}
}

View file

@ -0,0 +1,99 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderDerivedFields
{
/// <summary>
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
/// </summary>
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
if (workOrder.ScheduledDate == null)
return false;
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
return false;
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
}
public static bool IsPastDue(
DateTime? scheduledDate,
LifecycleStatus? lifecycleStatus,
DateTime? utcNow = null)
{
if (!scheduledDate.HasValue)
return false;
if (LifecycleStatusMapper.IsTerminal(lifecycleStatus))
return false;
var today = (utcNow ?? DateTime.UtcNow).Date;
return scheduledDate.Value.Date < today;
}
/// <summary>
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
/// </summary>
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
{
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
ApplyAutoScheduleIfEligible(workOrder);
}
/// <summary>
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
/// Week-only targets do not auto-schedule.
/// </summary>
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
{
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
return false;
if (workOrder.ScheduleWeekOnly == true)
return false;
if (workOrder.ScheduledDate == null)
return false;
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
return false;
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
return true;
}
public static string? GetDayGroup(DateTime? scheduledDate)
{
if (!scheduledDate.HasValue)
return null;
return scheduledDate.Value.DayOfWeek switch
{
DayOfWeek.Monday => "monday",
DayOfWeek.Tuesday => "tuesday",
DayOfWeek.Wednesday => "wednesday",
DayOfWeek.Thursday => "thursday",
DayOfWeek.Friday => "friday",
_ => null
};
}
public static string? FormatApptTime(DateTime? start, DateTime? end)
{
if (!start.HasValue)
return null;
if (end.HasValue)
return $"{start.Value:HH:mm} – {end.Value:HH:mm}";
return start.Value.ToString("HH:mm");
}
public static string GetLifecycleStatusLabel(LifecycleStatus? status)
=> status.HasValue ? LifecycleStatusMapper.ToFeLabel(status.Value) : "";
}
}

View file

@ -0,0 +1,50 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Fixed board flag palette (must match FE FLAG_COLORS). Values stored as canonical #RRGGBB.
/// </summary>
public static class WorkOrderFlagColors
{
public static readonly HashSet<string> Allowed = new(StringComparer.OrdinalIgnoreCase)
{
"#EF4444",
"#F97316",
"#F59E0B",
"#EAB308",
"#22C55E",
"#14B8A6",
"#3B82F6",
"#8B5CF6"
};
/// <summary>
/// Normalizes to # + 6 uppercase hex digits, or null if empty.
/// Returns false if the value is non-empty but not a valid hex color.
/// </summary>
public static bool TryNormalize(string? value, out string? canonical)
{
canonical = null;
if (string.IsNullOrWhiteSpace(value))
return true;
var trimmed = value.Trim();
if (trimmed.Length == 6 && trimmed[0] != '#')
trimmed = "#" + trimmed;
if (trimmed.Length != 7 || trimmed[0] != '#')
return false;
for (var i = 1; i < 7; i++)
{
if (!Char.IsAsciiHexDigit(trimmed[i]))
return false;
}
canonical = "#" + trimmed[1..].ToUpperInvariant();
return true;
}
public static bool IsAllowed(string? canonical)
=> canonical != null && Allowed.Contains(canonical);
}
}

View file

@ -0,0 +1,27 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>DynamoDB / ingest field mapping shared by Sync and direct ingest.</summary>
public static class WorkOrderIngestFieldMapper
{
public 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",
_ => string.IsNullOrWhiteSpace(dynamoStatus) ? null : "Open"
};
}
public static string? MapSeverityToPriority(string? severity)
{
if (string.IsNullOrWhiteSpace(severity)) return null;
return $"Sev {severity}";
}
}
}

View file

@ -0,0 +1,91 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderMediaProjection
{
public static List<WorkOrderMediaFileDto> ProjectAll(
WorkOrder workOrder,
IEnumerable<WorkOrderAttachments> attachments)
{
var media = new List<WorkOrderMediaFileDto>();
var attachmentId = -1;
if (!string.IsNullOrWhiteSpace(workOrder.BeforPhotoAttachment))
{
media.Add(new WorkOrderMediaFileDto
{
Id = attachmentId--,
Category = WorkOrderMediaCategory.Before,
Url = workOrder.BeforPhotoAttachment,
FileName = ExtractFileName(workOrder.BeforPhotoAttachment),
IsLegacy = true
});
}
if (!string.IsNullOrWhiteSpace(workOrder.AfterPhotoAttachment))
{
media.Add(new WorkOrderMediaFileDto
{
Id = attachmentId--,
Category = WorkOrderMediaCategory.After,
Url = workOrder.AfterPhotoAttachment,
FileName = ExtractFileName(workOrder.AfterPhotoAttachment),
IsLegacy = true
});
}
if (!string.IsNullOrWhiteSpace(workOrder.SignOffAttachment))
{
media.Add(new WorkOrderMediaFileDto
{
Id = attachmentId--,
Category = WorkOrderMediaCategory.Completion,
Url = workOrder.SignOffAttachment,
FileName = ExtractFileName(workOrder.SignOffAttachment),
IsLegacy = true
});
}
foreach (var attachment in attachments.Where(a => a.IsDeleted != true))
{
if (string.IsNullOrWhiteSpace(attachment.Attachments))
continue;
media.Add(new WorkOrderMediaFileDto
{
Id = attachment.Id,
Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
Url = attachment.Attachments,
FileName = ExtractFileName(attachment.Attachments),
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
IsLegacy = false
});
}
return media;
}
private static string? ExtractFileName(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
var lastSlash = url.LastIndexOf('/');
var segment = lastSlash >= 0 && lastSlash < url.Length - 1
? url[(lastSlash + 1)..]
: url;
try
{
return Uri.UnescapeDataString(segment);
}
catch (Exception)
{
return segment;
}
}
}
}

View file

@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderNumberNormalizer
{
private const int RequiredLength = 11;
public static bool TryNormalize(string? input, out string normalized, out string? error)
{
normalized = "";
error = null;
if (string.IsNullOrWhiteSpace(input))
{
error = "WO number is required.";
return false;
}
var digits = Regex.Replace(input.Trim(), @"\D", "");
if (digits.Length == 0)
{
error = "WO number must contain digits.";
return false;
}
if (digits.Length > RequiredLength)
{
error = $"WO number cannot exceed {RequiredLength} digits.";
return false;
}
normalized = digits.PadLeft(RequiredLength, '0');
return true;
}
}
}

View file

@ -0,0 +1,20 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderOperationalWeek
{
public static DateOnly GetMondayOfWeek(DateOnly date)
{
var daysFromMonday = ((int)date.DayOfWeek + 6) % 7;
return date.AddDays(-daysFromMonday);
}
public static DateOnly GetPreviousOperationalWeekStart(DateTime utcNow)
=> GetMondayOfWeek(DateOnly.FromDateTime(utcNow)).AddDays(-7);
public static DateOnly GetOperationalWeekEnd(DateOnly weekStartMonday)
=> weekStartMonday.AddDays(4);
public static string BuildWeekCorrelationId(DateOnly sourceWeekStart)
=> $"week:{sourceWeekStart:yyyy-MM-dd}";
}
}

View file

@ -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(4));
}
private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today)
{
var start = GetIsoWeekStart(today).AddDays(-7);
return new WorkOrderSearchDateRange(start, start.AddDays(4));
}
private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today)
{
var start = GetIsoWeekStart(today).AddDays(7);
return new WorkOrderSearchDateRange(start, start.AddDays(4));
}
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);
}
}
}

View file

@ -0,0 +1,47 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
{
type = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"pm" => Set(WorkOrderType.PM, out type),
"reactive" => Set(WorkOrderType.Reactive, out type),
"emergency" => Set(WorkOrderType.Emergency, out type),
"add-on" or "addon" => Set(WorkOrderType.AddOn, out type),
"po" => Set(WorkOrderType.PO, out type),
"project" => Set(WorkOrderType.Project, out type),
"inspection" => Set(WorkOrderType.Inspection, out type),
"other" => Set(WorkOrderType.Other, out type),
"overdue" => false,
_ => Enum.TryParse(label, true, out type)
};
}
public static string ToFeLabel(WorkOrderType type) => type switch
{
WorkOrderType.PM => "PM",
WorkOrderType.Reactive => "Reactive",
WorkOrderType.Emergency => "Emergency",
WorkOrderType.AddOn => "Add-On",
WorkOrderType.PO => "PO",
WorkOrderType.Project => "Project",
WorkOrderType.Inspection => "Inspection",
WorkOrderType.Other => "Other",
_ => type.ToString()
};
private static bool Set(WorkOrderType value, out WorkOrderType type)
{
type = value;
return true;
}
}
}

View file

@ -64,6 +64,20 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<IReadOnlyList<SiteOptionDTO>> GetSiteOptionsAsync(string? search = null)
{
var rows = await _locationDataService.GetSiteOptionsAsync(search);
return rows
.Select(r => new SiteOptionDTO
{
Id = r.Id,
Code = r.Code,
City = r.City,
State = r.State
})
.ToList();
}
public async Task<LocationDTO> CreateLocationAsync(CreateLocationDTO dto, string userId)
{
var validationResult = await _createValidator.ValidateAsync(dto);

View file

@ -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<PastDueCacheService> _logger;
public PastDueCacheService(
IWorkOrderDomainJobDataService dataService,
ILogger<PastDueCacheService> logger)
{
_dataService = dataService;
_logger = logger;
}
public async Task<PastDueCacheJobResult> 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;
}
}
}

View file

@ -0,0 +1,219 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class SyncFieldMergePolicy : ISyncFieldMergePolicy
{
private static readonly HashSet<string> ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
{
WorkOrderFieldNames.LifecycleStatus,
WorkOrderFieldNames.AssignTo,
WorkOrderFieldNames.ScheduledDate,
WorkOrderFieldNames.ScheduledEnd,
WorkOrderFieldNames.TargetWeek,
WorkOrderFieldNames.ScheduleWeekOnly,
WorkOrderFieldNames.DueDate,
WorkOrderFieldNames.Description,
WorkOrderFieldNames.WorkerOrderTitle,
WorkOrderFieldNames.SiteCode,
WorkOrderFieldNames.Building,
WorkOrderFieldNames.LocationId,
WorkOrderFieldNames.WorkOrderType,
WorkOrderFieldNames.Service,
WorkOrderFieldNames.DocStatus,
WorkOrderFieldNames.Trade,
WorkOrderFieldNames.Problem,
WorkOrderFieldNames.Priority,
WorkOrderFieldNames.InternalWONumber,
WorkOrderFieldNames.Status,
};
private readonly IWorkOrderFieldLockService _fieldLocks;
public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks)
{
_fieldLocks = fieldLocks;
}
public IReadOnlySet<string> ShocOwnedFields => ShocOwnedFieldSet;
public async Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName)
=> !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
public async Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
{
if (incomingValue == null) return false;
if (await context.FieldLocks.IsLockedAsync(context.WorkOrder.Id, fieldName))
{
var current = GetFieldValue(context.WorkOrder, fieldName);
await context.Audit.LogSyncRejectedAsync(
context.WorkOrder.Id,
fieldName,
current,
incomingValue.ToString());
return false;
}
return TryApplyField(context.WorkOrder, fieldName, incomingValue);
}
private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch
{
WorkOrderFieldNames.Description => wo.Description,
WorkOrderFieldNames.WorkerOrderTitle => wo.WorkerOrderTitle,
WorkOrderFieldNames.Status => wo.Status,
WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
WorkOrderFieldNames.SiteCode => wo.SiteCode,
WorkOrderFieldNames.Building => wo.Building,
WorkOrderFieldNames.Priority => wo.Priority,
WorkOrderFieldNames.AssignTo => wo.AssignTo,
WorkOrderFieldNames.LocationId => wo.LocationId?.ToString(),
WorkOrderFieldNames.LifecycleStatus => wo.LifecycleStatus?.ToString(),
WorkOrderFieldNames.WorkOrderType => wo.WorkOrderType?.ToString(),
WorkOrderFieldNames.Service => wo.Service,
WorkOrderFieldNames.DocStatus => wo.DocStatus?.ToString(),
WorkOrderFieldNames.Trade => wo.Trade,
WorkOrderFieldNames.Problem => wo.Problem,
WorkOrderFieldNames.InternalWONumber => wo.InternalWONumber,
_ => null
};
private static bool TryApplyField(WorkOrder wo, string fieldName, object incomingValue)
{
switch (fieldName)
{
case WorkOrderFieldNames.Description:
wo.Description = incomingValue.ToString();
return true;
case WorkOrderFieldNames.WorkerOrderTitle:
wo.WorkerOrderTitle = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Status:
wo.Status = incomingValue.ToString();
return true;
case WorkOrderFieldNames.DueDate when TryParseDateTime(incomingValue, out var dueDate):
wo.DueDate = dueDate;
return true;
case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
wo.ScheduledDate = scheduledDate;
return true;
case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
wo.ScheduledEnd = scheduledEnd;
return true;
case WorkOrderFieldNames.TargetWeek when TryParseDateOnly(incomingValue, out var targetWeek):
wo.TargetWeek = targetWeek;
return true;
case WorkOrderFieldNames.ScheduleWeekOnly when TryParseBool(incomingValue, out var scheduleWeekOnly):
wo.ScheduleWeekOnly = scheduleWeekOnly;
return true;
case WorkOrderFieldNames.SiteCode:
wo.SiteCode = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Building:
wo.Building = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Priority:
wo.Priority = incomingValue.ToString();
return true;
case WorkOrderFieldNames.AssignTo:
wo.AssignTo = incomingValue.ToString();
return true;
case WorkOrderFieldNames.LocationId when TryParseInt(incomingValue, out var locationId):
wo.LocationId = locationId;
return true;
case WorkOrderFieldNames.LifecycleStatus when TryParseEnum(incomingValue, out LifecycleStatus lifecycleStatus):
wo.LifecycleStatus = lifecycleStatus;
return true;
case WorkOrderFieldNames.WorkOrderType when TryParseEnum(incomingValue, out WorkOrderType workOrderType):
wo.WorkOrderType = workOrderType;
return true;
case WorkOrderFieldNames.Service:
wo.Service = incomingValue.ToString();
return true;
case WorkOrderFieldNames.DocStatus when TryParseEnum(incomingValue, out DocStatus docStatus):
wo.DocStatus = docStatus;
return true;
case WorkOrderFieldNames.Trade:
wo.Trade = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Problem:
wo.Problem = incomingValue.ToString();
return true;
case WorkOrderFieldNames.InternalWONumber:
wo.InternalWONumber = incomingValue.ToString();
return true;
default:
return false;
}
}
private static bool TryParseDateTime(object value, out DateTime result)
{
if (value is DateTime dt)
{
result = dt;
return true;
}
return DateTime.TryParse(value.ToString(), out result);
}
private static bool TryParseDateOnly(object value, out DateOnly result)
{
if (value is DateOnly dateOnly)
{
result = dateOnly;
return true;
}
if (value is DateTime dt)
{
result = DateOnly.FromDateTime(dt);
return true;
}
return DateOnly.TryParse(value.ToString(), out result);
}
private static bool TryParseBool(object value, out bool result)
{
if (value is bool b)
{
result = b;
return true;
}
return bool.TryParse(value.ToString(), out result);
}
private static bool TryParseInt(object value, out int result)
{
if (value is int i)
{
result = i;
return true;
}
return int.TryParse(value.ToString(), out result);
}
private static bool TryParseEnum<TEnum>(object value, out TEnum result) where TEnum : struct, Enum
{
if (value is TEnum enumValue)
{
result = enumValue;
return true;
}
return Enum.TryParse(value.ToString(), true, out result);
}
}
}

View file

@ -0,0 +1,66 @@
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 = 100;
private const int MaxPageSize = 100;
private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService)
{
_searchDataService = searchDataService;
}
public async Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
WorkOrderAdvancedSearchQueryDto query,
string? currentUserId)
{
var page = query.Page < 0 ? 0 : query.Page;
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
var dateRange = WorkOrderSearchDateRangeResolver.Resolve(
query.DatePreset,
query.DateFrom,
query.DateTo);
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
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,
sortBy,
query.SortDir ?? "asc");
var result = await _searchDataService.SearchAsync(dataQuery);
var utcNow = DateTime.UtcNow;
return new PagedResult<WorkOrderBoardRowDto>
{
Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(),
TotalCount = result.TotalCount,
Page = page,
PageSize = pageSize,
PageBase = 0
};
}
}
}

Some files were not shown because too many files have changed in this diff Show more