Merge pull request #22 from Sea-Haven-Industries/feature/wo-flag-color

feat(work-orders): add board flagColor with fixed palette
This commit is contained in:
Alexandre Brandizzi 2026-07-24 14:32:41 -03:00 • committed by GitHub
commit 8be07a9c5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 4508 additions and 179 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

@ -59,7 +59,12 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch);
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
controller.ControllerContext = new ControllerContext
{

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

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

View file

@ -50,6 +50,10 @@ namespace Api.SeaHavenIndustries.Controllers
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,
@ -71,7 +75,11 @@ namespace Api.SeaHavenIndustries.Controllers
IWorkOrderBoardUpdateService boardUpdateService,
IWorkOrderBoardCreateService boardCreateService,
IWorkOrderBoardCancelService boardCancelService,
IWorkOrderAdvancedSearchService advancedSearchService)
IWorkOrderAdvancedSearchService advancedSearchService,
IWorkOrderDetailService workOrderDetailService,
IWorkOrderCommentService workOrderCommentService,
IWorkOrderCompletionService workOrderCompletionService,
IWorkOrderMediaService workOrderMediaService)
{
_userManager = userManager;
_workOrderService = workOrderService;
@ -93,6 +101,10 @@ namespace Api.SeaHavenIndustries.Controllers
_boardCreateService = boardCreateService;
_boardCancelService = boardCancelService;
_advancedSearchService = advancedSearchService;
_workOrderDetailService = workOrderDetailService;
_workOrderCommentService = workOrderCommentService;
_workOrderCompletionService = workOrderCompletionService;
_workOrderMediaService = workOrderMediaService;
}
/// <summary>
@ -268,6 +280,216 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[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 });
}
}
[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)

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,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,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

@ -1,6 +1,7 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Options;
namespace Api.SeaHavenIndustries.HostedServices
{
@ -33,8 +34,8 @@ namespace Api.SeaHavenIndustries.HostedServices
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (_lastRunDate != today)
{
_lastRunDate = today;
await RunJobAsync(stoppingToken);
if (await RunJobAsync(stoppingToken))
_lastRunDate = today;
}
}
@ -42,7 +43,7 @@ namespace Api.SeaHavenIndustries.HostedServices
}
}
private async Task RunJobAsync(CancellationToken stoppingToken)
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
{
try
{
@ -52,11 +53,13 @@ namespace Api.SeaHavenIndustries.HostedServices
_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;
}
}
@ -67,8 +70,7 @@ namespace Api.SeaHavenIndustries.HostedServices
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
runAt = new TimeOnly(0, 10);
var current = TimeOnly.FromDateTime(now);
return current >= runAt && current < runAt.AddMinutes(1);
return TimeOnly.FromDateTime(now) >= runAt;
}
}
}

View file

@ -1,7 +1,8 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Options;
namespace Api.SeaHavenIndustries.HostedServices
{
@ -34,8 +35,8 @@ namespace Api.SeaHavenIndustries.HostedServices
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (_lastRunDate != today)
{
_lastRunDate = today;
await RunJobAsync(stoppingToken);
if (await RunJobAsync(stoppingToken))
_lastRunDate = today;
}
}
@ -43,7 +44,7 @@ namespace Api.SeaHavenIndustries.HostedServices
}
}
private async Task RunJobAsync(CancellationToken stoppingToken)
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
{
try
{
@ -55,11 +56,13 @@ namespace Api.SeaHavenIndustries.HostedServices
_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;
}
}
@ -75,8 +78,7 @@ namespace Api.SeaHavenIndustries.HostedServices
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
runAt = new TimeOnly(0, 5);
var current = TimeOnly.FromDateTime(now);
return current >= runAt && current < runAt.AddMinutes(1);
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

@ -1,11 +0,0 @@
namespace Api.SeaHavenIndustries.Options
{
/// <summary>In-memory last-run timestamps for hosted domain jobs (ops health).</summary>
public class WorkOrderJobRunState
{
public DateTime? LastWeekRolledRunUtc { get; set; }
public DateTime? LastPastDueCacheRunUtc { get; set; }
public string? LastWeekRolledError { get; set; }
public string? LastPastDueCacheError { get; set; }
}
}

View file

@ -1,5 +1,6 @@
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@ -11,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;
@ -59,9 +61,35 @@ builder.Services.AddBusinessServices();
builder.Services.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
builder.Services.AddSingleton<WorkOrderJobRunState>();
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 =>
{
@ -128,6 +156,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",
@ -34,5 +34,17 @@
"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

@ -42,6 +42,10 @@ namespace Data.SeaHavenIndustries
.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();
@ -56,6 +60,7 @@ namespace Data.SeaHavenIndustries
.Property(w => w.InternalWONumber)
.HasMaxLength(11);
builder.Entity<WorkOrder>()
.HasIndex(w => w.SiteCode)
.HasFilter("[istemplate] = 0");
@ -68,6 +73,16 @@ namespace Data.SeaHavenIndustries
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0");
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();
@ -110,6 +125,7 @@ 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; }

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,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ExternalWorkOrderId",
table: "workOrders",
type: "nvarchar(450)",
maxLength: 450,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_workOrders_ExternalWorkOrderId",
table: "workOrders",
column: "ExternalWorkOrderId",
unique: true,
filter: "[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_workOrders_ExternalWorkOrderId",
table: "workOrders");
migrationBuilder.AlterColumn<string>(
name: "ExternalWorkOrderId",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldMaxLength: 450,
oldNullable: true);
}
}
}

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

@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Accounts");
b.ToTable("Accounts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("Addresses");
b.ToTable("Addresses", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Assets");
b.ToTable("Assets", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
@ -371,7 +371,62 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Categories");
b.ToTable("Categories", (string)null);
});
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")
.HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive");
b.ToTable("CompletionDocTemplates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
@ -438,7 +493,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkerOrderId");
b.ToTable("Comments");
b.ToTable("Comments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
@ -504,7 +559,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("ContactDetails");
b.ToTable("ContactDetails", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
@ -596,7 +651,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Contacts");
b.ToTable("Contacts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
@ -614,7 +669,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Departments");
b.ToTable("Departments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
@ -704,9 +759,17 @@ namespace Data.SeaHavenIndustries.Migrations
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");
@ -725,7 +788,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("Dispatches");
b.ToTable("Dispatches", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
@ -784,7 +847,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchChecklistItems");
b.ToTable("DispatchChecklistItems", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
@ -838,7 +901,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("DispatchId");
b.ToTable("DispatchSignoffs");
b.ToTable("DispatchSignoffs", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
@ -908,7 +971,7 @@ namespace Data.SeaHavenIndustries.Migrations
.IsUnique()
.HasFilter("[Status] = 'Pending'");
b.ToTable("DispatchUpliftRequests");
b.ToTable("DispatchUpliftRequests", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
@ -931,7 +994,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchWorkOrders");
b.ToTable("DispatchWorkOrders", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
@ -961,7 +1024,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("DropdownOptions");
b.ToTable("DropdownOptions", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
@ -1066,7 +1129,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("RegionId");
b.ToTable("Employees");
b.ToTable("Employees", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
@ -1106,7 +1169,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId")
.IsUnique();
b.ToTable("EmployeeAddresses");
b.ToTable("EmployeeAddresses", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
@ -1134,7 +1197,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeeEmails");
b.ToTable("EmployeeEmails", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
@ -1164,7 +1227,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeePhones");
b.ToTable("EmployeePhones", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
@ -1312,7 +1375,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("FollowUps");
b.ToTable("FollowUps", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
@ -1337,7 +1400,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("ForgetPasswordCodes");
b.ToTable("ForgetPasswordCodes", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
@ -1355,7 +1418,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("JobTitles");
b.ToTable("JobTitles", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
@ -1425,7 +1488,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Locations");
b.ToTable("Locations", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
@ -1483,7 +1546,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("PMSchedules");
b.ToTable("PMSchedules", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
@ -1557,7 +1620,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("Quotes");
b.ToTable("Quotes", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
@ -1605,7 +1668,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("QuoteId");
b.ToTable("QuotesLines");
b.ToTable("QuotesLines", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
@ -1623,7 +1686,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Regions");
b.ToTable("Regions", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
@ -1725,7 +1788,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("TaskListTemplates");
b.ToTable("TaskListTemplates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
@ -1770,7 +1833,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("TaskListTemplateId");
b.ToTable("TaskListTemplateItems");
b.ToTable("TaskListTemplateItems", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
@ -1872,7 +1935,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Templates");
b.ToTable("Templates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
@ -1960,7 +2023,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("CompanyId");
b.ToTable("Vendors");
b.ToTable("Vendors", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
@ -2019,7 +2082,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("VendorId");
b.ToTable("VendorAccessTokens");
b.ToTable("VendorAccessTokens", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
@ -2277,7 +2340,16 @@ namespace Data.SeaHavenIndustries.Migrations
.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")
.HasMaxLength(11)
@ -2313,6 +2385,18 @@ namespace Data.SeaHavenIndustries.Migrations
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");
@ -2346,6 +2430,10 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ServiceNotes")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
@ -2380,12 +2468,20 @@ namespace Data.SeaHavenIndustries.Migrations
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");
@ -2407,6 +2503,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("ExternalWorkOrderId")
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
b.HasIndex("InternalWONumber")
.HasFilter("[istemplate] = 0");
@ -2424,7 +2524,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderType");
b.ToTable("workOrders");
b.ToTable("workOrders", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -2438,6 +2538,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Attachments")
.HasColumnType("nvarchar(max)");
b.Property<int?>("Category")
.HasColumnType("int");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
@ -2466,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderAttachments");
b.ToTable("workOrderAttachments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
@ -2516,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("WorkOrderAuditLogs");
b.ToTable("WorkOrderAuditLogs", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
@ -2560,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderCategories");
b.ToTable("workOrderCategories", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
@ -2607,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("WorkOrderContacts");
b.ToTable("WorkOrderContacts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
@ -2636,7 +2739,36 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "FieldName")
.IsUnique();
b.ToTable("WorkOrderFieldLocks");
b.ToTable("WorkOrderFieldLocks", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<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", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>

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

@ -34,6 +34,13 @@ namespace Data.SeaHavenIndustries
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; }

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

@ -33,6 +33,25 @@ namespace Data.SeaHavenIndustries
[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; }
@ -52,6 +71,10 @@ namespace Data.SeaHavenIndustries
// --- 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; }

View file

@ -20,7 +20,10 @@ namespace SeaHaven.DataServices.Helpers
w.WorkOrderType,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
Poc = w.WorkOrderContacts!
WoPocName = w.PocName,
WoPocPhone = w.PocPhone,
WoPocNotes = w.PocNotes,
ContactPoc = w.WorkOrderContacts!
.OrderBy(c => c.Id)
.Select(c => new
{
@ -52,55 +55,73 @@ namespace SeaHaven.DataServices.Helpers
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.ContactName
: null,
TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != 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 => new WorkOrderBoardRawRow(
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
w.LocationName,
w.Poc?.Name,
w.Poc?.PhoneNumber,
w.Poc?.Notes,
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,
w.TechPhone,
w.DispatchApptDate,
w.Trade ?? w.Problem,
w.Problem,
w.DocStatus,
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
isUnscheduled)).ToList();
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

@ -143,5 +143,6 @@ namespace SeaHaven.DataServices.Helpers
}
#endregion
}
}

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,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

@ -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,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

@ -42,7 +42,10 @@ namespace SeaHaven.DataServices.Interfaces
DateTime? DispatchApptDate,
string? Trade,
string? Problem,
string? ServiceNotes,
string? ExtraServices,
DocStatus? DocStatus,
string? FlagColor,
int? PrimaryDispatchId,
byte[]? RowVersion,
byte[]? DispatchRowVersion,

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

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

@ -51,7 +51,11 @@ namespace SeaHaven.Services.DTOs
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; }

View file

@ -3,14 +3,25 @@ using SeaHaven.Services.Helpers;
namespace SeaHaven.Services.DTOs
{
/// <summary>Payload for creating a board work order (SHOC create wizard).</summary>
/// <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; }
@ -22,7 +33,11 @@ namespace SeaHaven.Services.DTOs
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>

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,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,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

@ -15,6 +15,10 @@ namespace SeaHaven.Services.Helpers
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)
@ -38,7 +42,11 @@ namespace SeaHaven.Services.Helpers
VendorId,
ApptDate,
ApptTime,
DocStatus
DocStatus,
Pm,
ServiceNotes,
ExtraServices,
FlagColor
};
public static bool IsEditable(string field) => Canonicalize(field) != null;
@ -75,6 +83,10 @@ namespace SeaHaven.Services.Helpers
ApptDate => "ApptDate",
ApptTime => "ApptTime",
DocStatus => "DocStatus",
Pm => "Trade",
ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices",
FlagColor => "FlagColor",
_ => canonical
};
}

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,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

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

@ -123,6 +123,5 @@ namespace SeaHaven.Services.Implementation
CreatedAt = DateTime.UtcNow
});
}
}
}

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentValidation;
@ -41,6 +42,24 @@ namespace SeaHaven.Services.Implementation
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
var siteCode = request.SiteCode!.Trim();
var primaryService = ResolvePrimaryService(request);
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
var serviceNotes = TrimOrNull(request.ServiceNotes);
var pocName = TrimOrNull(request.PocName);
var pocPhone = TrimOrNull(request.PocPhone);
var pocNotes = TrimOrNull(request.PocNotes);
var techPhone = TrimOrNull(request.TechPhone);
var vendorNotes = TrimOrNull(request.VendorNotes);
var hasVendorDispatch = request.VendorId.HasValue
|| request.ApptDate.HasValue
|| !string.IsNullOrWhiteSpace(request.ApptTime);
if (request.VendorId.HasValue && request.VendorId.Value > 0)
{
var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == request.VendorId.Value && v.IsDeleted != true);
if (!vendorExists)
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
}
var workOrder = new WorkOrder
{
@ -49,7 +68,12 @@ namespace SeaHaven.Services.Implementation
WorkOrderType = request.WorkOrderType,
SiteCode = siteCode,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Trade = string.IsNullOrWhiteSpace(request.Trade) ? null : request.Trade.Trim(),
Trade = primaryService,
ExtraServices = extraServicesJson,
ServiceNotes = serviceNotes,
PocName = pocName,
PocPhone = pocPhone,
PocNotes = pocNotes,
LocationId = request.LocationId,
DueDate = request.DueDate?.Date,
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
@ -61,6 +85,12 @@ namespace SeaHaven.Services.Implementation
istemplate = false
};
if (!hasVendorDispatch)
{
workOrder.TechPhone = techPhone;
workOrder.VendorNotes = vendorNotes;
}
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
@ -75,6 +105,20 @@ namespace SeaHaven.Services.Implementation
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
if (workOrder.Trade != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
if (workOrder.ExtraServices != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
if (workOrder.ServiceNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceNotes", "", workOrder.ServiceNotes));
if (workOrder.PocName != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocName", "", workOrder.PocName));
if (workOrder.PocPhone != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone));
if (workOrder.PocNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes));
if (workOrder.TechPhone != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone));
if (workOrder.VendorNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", workOrder.VendorNotes));
if (workOrder.LocationId.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
if (workOrder.DueDate.HasValue)
@ -95,7 +139,7 @@ namespace SeaHaven.Services.Implementation
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
Dispatch? dispatch = null;
if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime))
if (hasVendorDispatch)
{
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
@ -105,7 +149,9 @@ namespace SeaHaven.Services.Implementation
VendorId = request.VendorId.Value,
Status = "Pending",
CreatedDate = DateTime.UtcNow,
ScheduledDate = request.ApptDate?.Date
ScheduledDate = request.ApptDate?.Date,
TechPhone = techPhone,
VendorNotes = vendorNotes
};
_context.Dispatches.Add(dispatch);
workOrder.PrimaryDispatch = dispatch;
@ -120,6 +166,11 @@ namespace SeaHaven.Services.Implementation
if (apptChange.HasChanged)
changes.Add(apptChange);
}
if (techPhone != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", techPhone));
if (vendorNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", vendorNotes));
}
// Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and
@ -139,6 +190,7 @@ namespace SeaHaven.Services.Implementation
dispatch.WorkOrderId = workOrder.Id;
}
await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber);
await StageChangesAsync(workOrder.Id, changes, actorId, dispatch);
@ -168,6 +220,51 @@ namespace SeaHaven.Services.Implementation
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
}
private static string? ResolvePrimaryService(WorkOrderBoardCreateRequestDto request)
{
if (!string.IsNullOrWhiteSpace(request.PrimaryService))
return request.PrimaryService.Trim();
if (!string.IsNullOrWhiteSpace(request.Trade))
return request.Trade.Trim();
return null;
}
private static string? SerializeExtraServices(List<string>? extras)
{
if (extras == null || extras.Count == 0)
return null;
var normalized = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in extras)
{
if (string.IsNullOrWhiteSpace(item))
continue;
var trimmed = item.Trim();
if (trimmed.Length > 128)
throw new WorkOrderBoardValidationException(
"InvalidValue",
"Each extra service must be at most 128 characters.");
if (!seen.Add(trimmed))
continue;
normalized.Add(trimmed);
}
if (normalized.Count == 0)
return null;
var json = JsonSerializer.Serialize(normalized);
if (json.Length > 2000)
throw new WorkOrderBoardValidationException(
"InvalidValue",
"extraServices must serialize to at most 2000 characters.");
return json;
}
private static string? TrimOrNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private async Task<string> ResolveWoNumberAsync(string? requested)
{
if (!string.IsNullOrWhiteSpace(requested))

View file

@ -109,7 +109,10 @@ namespace SeaHaven.Services.Implementation
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
Pm = row.Trade ?? row.Problem,
ServiceNotes = row.ServiceNotes,
ExtraServices = ParseExtraServices(row.ExtraServices),
DocStatus = row.DocStatus,
FlagColor = row.FlagColor,
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion
@ -118,5 +121,21 @@ namespace SeaHaven.Services.Implementation
private static string FormatName(string? firstName, string? lastName)
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
private static List<string>? ParseExtraServices(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
return list is { Count: > 0 } ? list : null;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
}
}

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
@ -186,6 +187,10 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },
WorkOrderBoardFieldNames.FlagColor => new List<FieldChange> { ApplyFlagColor(workOrder, value, auditField) },
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
};
}
@ -260,6 +265,11 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
throw new WorkOrderBoardValidationException(
"InvalidValue",
"Overdue is not a work order type; it is derived from scheduledDate (isPastDue).");
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
@ -286,6 +296,89 @@ namespace SeaHaven.Services.Implementation
return FieldChange.ForField(auditField, old, normalized);
}
private static FieldChange ApplyServiceNotes(WorkOrder workOrder, string? value, string auditField)
{
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
if (normalized != null && normalized.Length > 4000)
throw new WorkOrderBoardValidationException("InvalidValue", "serviceNotes must be at most 4000 characters.");
var old = workOrder.ServiceNotes;
if (old == normalized)
return FieldChange.Unchanged(auditField);
workOrder.ServiceNotes = normalized;
return FieldChange.ForField(auditField, old, normalized);
}
private static FieldChange ApplyExtraServices(WorkOrder workOrder, string? value, string auditField)
{
string? normalizedJson = null;
if (!string.IsNullOrWhiteSpace(value))
{
List<string>? parsed;
try
{
parsed = JsonSerializer.Deserialize<List<string>>(value.Trim());
}
catch (JsonException)
{
throw new WorkOrderBoardValidationException("InvalidValue", "extraServices must be a JSON string array.");
}
var items = NormalizeExtraServiceList(parsed);
if (items.Count > 0)
{
if (string.IsNullOrWhiteSpace(workOrder.Trade))
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
"primaryService (pm/trade) is required when extraServices is provided.");
}
items = items
.Where(s => !string.Equals(s, workOrder.Trade, StringComparison.OrdinalIgnoreCase))
.ToList();
normalizedJson = items.Count > 0 ? JsonSerializer.Serialize(items) : null;
if (normalizedJson != null && normalizedJson.Length > 2000)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
"extraServices must serialize to at most 2000 characters.");
}
}
}
var old = workOrder.ExtraServices;
if (old == normalizedJson)
return FieldChange.Unchanged(auditField);
workOrder.ExtraServices = normalizedJson;
return FieldChange.ForField(auditField, old, normalizedJson);
}
private static List<string> NormalizeExtraServiceList(List<string>? extras)
{
var normalized = new List<string>();
if (extras == null || extras.Count == 0)
return normalized;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in extras)
{
if (string.IsNullOrWhiteSpace(item))
continue;
var trimmed = item.Trim();
if (trimmed.Length > 128)
throw new WorkOrderBoardValidationException("InvalidValue", "Each extra service must be at most 128 characters.");
if (!seen.Add(trimmed))
continue;
normalized.Add(trimmed);
}
return normalized;
}
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
{
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
@ -503,6 +596,24 @@ namespace SeaHaven.Services.Implementation
return FieldChange.ForField(auditField, old, parsed.ToString());
}
private static FieldChange ApplyFlagColor(WorkOrder workOrder, string? value, string auditField)
{
if (!WorkOrderFlagColors.TryNormalize(value, out var canonical))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid flagColor: {value}");
if (canonical != null && !WorkOrderFlagColors.IsAllowed(canonical))
throw new WorkOrderBoardValidationException(
"InvalidValue",
$"flagColor must be one of the allowed palette values or empty to clear.");
var old = workOrder.FlagColor;
if (string.Equals(old, canonical, StringComparison.OrdinalIgnoreCase))
return FieldChange.Unchanged(auditField);
workOrder.FlagColor = canonical;
return FieldChange.ForField(auditField, old, canonical);
}
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<FieldChange>();

View file

@ -0,0 +1,59 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderCommentService : IWorkOrderCommentService
{
private readonly IWorkOrderDetailDataService _detailData;
private readonly ICommentDataService _commentData;
public WorkOrderCommentService(
IWorkOrderDetailDataService detailData,
ICommentDataService commentData)
{
_detailData = detailData;
_commentData = commentData;
}
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
{
if (!await _detailData.ExistsAsync(workOrderId))
return null;
var comments = await _detailData.GetCommentsAsync(workOrderId);
return WorkOrderCommentProjection.ToDtos(comments);
}
public async Task<WorkOrderCommentDto> AddCommentAsync(
int workOrderId,
WorkOrderCommentCreateDto request,
string? actorId,
string? documentUrl = null)
{
if (!await _detailData.ExistsAsync(workOrderId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (string.IsNullOrWhiteSpace(request.Text))
throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required.");
var comment = new Comments
{
WorkerOrderId = workOrderId,
UserId = actorId,
Commenttext = request.Text.Trim(),
CommentType = "General",
RecordType = "WorkOrder",
Documents = documentUrl,
CreatedDate = DateTime.UtcNow
};
var saved = await _commentData.AddAsync(comment);
return WorkOrderCommentProjection.ToDto(saved);
}
}
}

View file

@ -0,0 +1,178 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderCompletionService : IWorkOrderCompletionService
{
private readonly ApplicationDbContext _context;
private readonly ICompletionDocTemplateDataService _templateData;
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
public WorkOrderCompletionService(
ApplicationDbContext context,
ICompletionDocTemplateDataService templateData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService)
{
_context = context;
_templateData = templateData;
_detailData = detailData;
_auditService = auditService;
}
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType);
return rows.Select(WorkOrderDetailService.MapTemplate).ToList();
}
public async Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id)
{
var row = await _templateData.GetByIdAsync(id);
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
}
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId)
{
var workOrder = await _context.workOrders
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
if (workOrderVersion == null)
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion))
throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry.");
_context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion;
var oldDocStatus = workOrder.DocStatus?.ToString();
var oldAttachment = workOrder.SignOffAttachment;
workOrder.SignOffAttachment = fileUrl;
if (!string.IsNullOrWhiteSpace(request.SignOffName))
workOrder.SignOffName = request.SignOffName.Trim();
if (!string.IsNullOrWhiteSpace(request.SignOffSignature))
workOrder.SignOffSignature = request.SignOffSignature.Trim();
workOrder.DocStatus = DocStatus.Yes;
await _auditService.StageFieldChangedAsync(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId);
if (oldDocStatus != DocStatus.Yes.ToString())
await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
await _context.SaveChangesAsync();
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
return new WorkOrderCompletionDto
{
DocStatus = workOrder.DocStatus,
Template = template == null ? null : WorkOrderDetailService.MapTemplate(template),
SignOffName = extended?.SignOffName,
SignOffAttachment = extended?.SignOffAttachment,
SignOffSignature = extended?.SignOffSignature,
DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto
{
DispatchId = s.DispatchId,
SignoffType = s.SignoffType,
Name = s.Name,
SignedAt = s.SignedAt?.ToUniversalTime().ToString("o")
}).ToList()
};
}
public async Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request)
{
ValidateTemplateRequest(request);
var entity = new CompletionDocTemplate
{
Name = request.Name.Trim(),
ServiceKey = request.ServiceKey.Trim(),
WorkOrderType = request.WorkOrderType,
TemplateUrl = request.TemplateUrl.Trim(),
IsActive = request.IsActive
};
var created = await _templateData.CreateAsync(entity);
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive));
}
public async Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request)
{
ValidateTemplateRequest(request);
var entity = await _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
if (entity == null)
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
entity.Name = request.Name.Trim();
entity.ServiceKey = request.ServiceKey.Trim();
entity.WorkOrderType = request.WorkOrderType;
entity.TemplateUrl = request.TemplateUrl.Trim();
entity.IsActive = request.IsActive;
await _templateData.UpdateAsync(entity);
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive));
}
public async Task DeleteTemplateAsync(int id)
{
if (!await _templateData.DeleteAsync(id))
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
}
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))
throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required.");
if (string.IsNullOrWhiteSpace(request.TemplateUrl))
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required.");
if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
}
}
private static byte[]? ParseRowVersion(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return null;
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
}
}
}
}

View file

@ -0,0 +1,182 @@
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderDetailService : IWorkOrderDetailService
{
private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderDetailDataService _detailData;
private readonly ICompletionDocTemplateDataService _templateData;
private readonly IUserDataService _userDataService;
public WorkOrderDetailService(
IWorkOrderBoardService boardService,
IWorkOrderDetailDataService detailData,
ICompletionDocTemplateDataService templateData,
IUserDataService userDataService)
{
_boardService = boardService;
_detailData = detailData;
_templateData = templateData;
_userDataService = userDataService;
}
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId)
{
if (!await _detailData.ExistsAsync(workOrderId))
return null;
var boardRow = await _boardService.GetBoardRowAsync(workOrderId);
if (boardRow == null)
return null;
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
var comments = await _detailData.GetCommentsAsync(workOrderId);
var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50);
var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
var template = await _templateData.ResolveForWorkOrderAsync(
extended?.Trade ?? boardRow.Pm,
boardRow.WorkOrderType);
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
return new WorkOrderDetailDto
{
Info = MapInfo(boardRow, extended),
Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs),
Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames),
Audit = await MapAuditAsync(audit),
Media = workOrder == null
? new List<WorkOrderMediaFileDto>()
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
};
}
public async Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50)
{
if (!await _detailData.ExistsAsync(workOrderId))
return null;
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);
return await MapAuditAsync(logs);
}
private async Task<List<WorkOrderAuditEntryDto>> MapAuditAsync(IReadOnlyList<Data.SeaHavenIndustries.WorkOrderAuditLog> logs)
{
var userIds = WorkOrderAuditProjection.CollectAssignToUserIds(logs);
var displayNames = await _userDataService.GetDisplayNamesByIdsAsync(userIds);
return WorkOrderAuditProjection.ToDtos(logs, displayNames);
}
private static WorkOrderDetailInfoDto MapInfo(
WorkOrderBoardRowDto row,
WorkOrderDetailExtendedFields? extended)
{
return new WorkOrderDetailInfoDto
{
Id = row.Id,
WoNumber = row.WoNumber,
RescheduleCount = row.RescheduleCount,
CarriedOver = row.CarriedOver,
WorkOrderType = row.WorkOrderType,
IsPastDue = row.IsPastDue,
SiteCode = row.SiteCode,
LocationName = row.LocationName,
PocName = row.PocName,
PocPhone = row.PocPhone,
PocNotes = row.PocNotes,
LifecycleStatus = row.LifecycleStatus,
LifecycleStatusLabel = row.LifecycleStatusLabel,
LegacyStatus = row.LegacyStatus,
DispatcherId = row.DispatcherId,
DispatcherName = row.DispatcherName,
Initials = row.Initials,
Color = row.Color,
DueDate = row.DueDate,
ScheduledDate = row.ScheduledDate,
TargetWeek = row.TargetWeek,
ScheduleWeekOnly = row.ScheduleWeekOnly,
DayGroup = row.DayGroup,
VendorId = row.VendorId,
VendorName = row.VendorName,
TechName = row.TechName,
TechPhone = row.TechPhone,
ApptDate = row.ApptDate,
ApptTime = row.ApptTime,
Pm = row.Pm,
ServiceNotes = extended?.ServiceNotes ?? row.ServiceNotes,
ExtraServices = ParseExtraServices(extended?.ExtraServices) ?? row.ExtraServices,
DocStatus = row.DocStatus,
FlagColor = row.FlagColor,
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion,
Description = extended?.Description,
Trade = extended?.Trade,
Problem = extended?.Problem,
SubTrade = extended?.SubTrade,
OriginalWeek = extended?.OriginalWeek,
OriginalDate = extended?.OriginalDate,
CompletedDate = extended?.CompletedDate,
VendorNotes = extended?.VendorNotes
};
}
private static List<string>? ParseExtraServices(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
return list is { Count: > 0 } ? list : null;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
private static WorkOrderCompletionDto MapCompletion(
WorkOrderDetailExtendedFields? extended,
Data.SeaHavenIndustries.Enums.DocStatus? docStatus,
CompletionDocTemplateRow? template,
IReadOnlyList<DispatchSignoffRow> signoffs)
{
return new WorkOrderCompletionDto
{
DocStatus = docStatus,
Template = template == null ? null : MapTemplate(template),
SignOffName = extended?.SignOffName,
SignOffAttachment = extended?.SignOffAttachment,
SignOffSignature = extended?.SignOffSignature,
DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto
{
DispatchId = s.DispatchId,
SignoffType = s.SignoffType,
Name = s.Name,
SignedAt = s.SignedAt?.ToUniversalTime().ToString("o")
}).ToList()
};
}
internal static CompletionDocTemplateDto MapTemplate(CompletionDocTemplateRow template)
=> new()
{
Id = template.Id,
Name = template.Name,
ServiceKey = template.ServiceKey,
WorkOrderType = template.WorkOrderType,
TemplateUrl = template.TemplateUrl,
IsActive = template.IsActive
};
}
}

View file

@ -0,0 +1,216 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderIngestService : IWorkOrderIngestService
{
private readonly ApplicationDbContext _db;
private readonly ISyncFieldMergePolicy _mergePolicy;
private readonly IWorkOrderFieldLockService _fieldLocks;
private readonly IWorkOrderAuditService _audit;
public WorkOrderIngestService(
ApplicationDbContext db,
ISyncFieldMergePolicy mergePolicy,
IWorkOrderFieldLockService fieldLocks,
IWorkOrderAuditService audit)
{
_db = db;
_mergePolicy = mergePolicy;
_fieldLocks = fieldLocks;
_audit = audit;
}
public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
IReadOnlyList<WorkOrderIngestPayloadDto> items,
CancellationToken cancellationToken = default)
{
var result = new WorkOrderIngestBatchResultDto();
if (items.Count == 0)
return result;
var useTransaction = _db.Database.IsRelational();
await using var transaction = useTransaction
? await _db.Database.BeginTransactionAsync(cancellationToken)
: null;
try
{
var nextSeed = await AllocateNextWoSeedAsync(cancellationToken);
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item.ExternalWorkOrderId))
{
result.Skipped++;
continue;
}
var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken);
nextSeed = updatedSeed;
result.Results.Add(upsert);
if (upsert.Created)
result.Created++;
else
result.Updated++;
}
await _db.SaveChangesAsync(cancellationToken);
if (transaction != null)
await transaction.CommitAsync(cancellationToken);
}
catch
{
if (transaction != null)
await transaction.RollbackAsync(cancellationToken);
throw;
}
return result;
}
private async Task<(WorkOrderIngestItemResultDto Result, int NextSeed)> UpsertOneAsync(
WorkOrderIngestPayloadDto item,
int nextSeed,
CancellationToken cancellationToken)
{
var existing = await _db.workOrders
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == item.ExternalWorkOrderId, cancellationToken);
var locationId = await ResolveLocationIdAsync(
item.SiteCode, item.Building, item.Address, cancellationToken);
if (existing == null)
{
var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed);
var wo = new WorkOrder
{
InternalWONumber = woNumber,
ExternalWorkOrderId = item.ExternalWorkOrderId,
WorkerOrderNumber = item.ExternalWorkOrderId,
WorkerOrderTitle = item.Description,
Description = item.Description,
Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus) ?? "Open",
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
Severity = item.Severity,
Customer = item.Customer,
SiteCode = item.SiteCode,
Building = item.Building,
LocationId = locationId,
DueDate = item.DueDate,
DateReported = item.DateReported,
ScheduledStart = item.ScheduledStart,
SourceEmailS3Key = item.SourceEmailS3Key,
CreatedDate = item.CreatedAt ?? DateTime.UtcNow,
istemplate = false
};
_db.workOrders.Add(wo);
await _db.SaveChangesAsync(cancellationToken);
return (new WorkOrderIngestItemResultDto
{
ExternalWorkOrderId = item.ExternalWorkOrderId,
WorkOrderId = wo.Id,
Created = true
}, updatedSeed);
}
var syncContext = new WorkOrderSyncContext
{
WorkOrder = existing,
FieldLocks = _fieldLocks,
Audit = _audit
};
if (item.Description != null)
{
await _mergePolicy.TryApplyAsync(syncContext, "Description", item.Description);
await _mergePolicy.TryApplyAsync(syncContext, "WorkerOrderTitle", item.Description);
}
var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
if (mappedStatus != null)
await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus);
var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity);
if (priority != null)
await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority);
if (item.Severity != null)
await _mergePolicy.TryApplyAsync(syncContext, "Severity", item.Severity);
if (item.SiteCode != null)
await _mergePolicy.TryApplyAsync(syncContext, "SiteCode", item.SiteCode);
if (item.Building != null)
await _mergePolicy.TryApplyAsync(syncContext, "Building", item.Building);
if (locationId.HasValue)
await _mergePolicy.TryApplyAsync(syncContext, "LocationId", locationId.Value);
if (item.DueDate.HasValue)
await _mergePolicy.TryApplyAsync(syncContext, "DueDate", item.DueDate.Value);
if (item.Customer != null)
existing.Customer = item.Customer;
// Sync-owned metadata (not in ShocOwnedFieldSet)
existing.DateReported = item.DateReported ?? existing.DateReported;
existing.ScheduledStart = item.ScheduledStart ?? existing.ScheduledStart;
existing.SourceEmailS3Key = item.SourceEmailS3Key ?? existing.SourceEmailS3Key;
return (new WorkOrderIngestItemResultDto
{
ExternalWorkOrderId = item.ExternalWorkOrderId,
WorkOrderId = existing.Id,
Created = false
}, nextSeed);
}
private async Task<int> AllocateNextWoSeedAsync(CancellationToken cancellationToken)
{
var maxId = await _db.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
return Math.Max(maxId + 1, 1);
}
private static (string Number, int NextSeed) AllocateInternalWoNumber(int seed)
{
if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _))
normalized = seed.ToString().PadLeft(11, '0');
return (normalized, seed + 1);
}
private async Task<int?> ResolveLocationIdAsync(
string? siteCode,
string? building,
string? address,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
return null;
var matchCode = siteCode ?? building;
var existing = await _db.Locations
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken);
if (existing != null)
return existing.Id;
var location = new Locations
{
Name = matchCode,
Title = building,
Address1 = address,
Status = "Active"
};
_db.Locations.Add(location);
await _db.SaveChangesAsync(cancellationToken);
return location.Id;
}
}
}

View file

@ -0,0 +1,134 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderMediaService : IWorkOrderMediaService
{
private readonly ApplicationDbContext _context;
private readonly IWorkOrderDetailDataService _detailData;
public WorkOrderMediaService(ApplicationDbContext context, IWorkOrderDetailDataService detailData)
{
_context = context;
_detailData = detailData;
}
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId)
{
if (!await _detailData.ExistsAsync(workOrderId))
return null;
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
if (workOrder == null)
return null;
var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
}
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
int workOrderId,
WorkOrderMediaCategory category,
string fileUrl,
string? actorId)
{
if (!await _detailData.ExistsAsync(workOrderId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
if (category == WorkOrderMediaCategory.Completion)
{
throw new WorkOrderBoardValidationException(
"UseCompletionDocEndpoint",
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
}
if (category == WorkOrderMediaCategory.Before)
{
workOrder.BeforPhotoAttachment = fileUrl;
await _context.SaveChangesAsync();
return new WorkOrderMediaFileDto
{
Id = -1,
Category = category,
Url = fileUrl,
IsLegacy = true
};
}
if (category == WorkOrderMediaCategory.After)
{
workOrder.AfterPhotoAttachment = fileUrl;
await _context.SaveChangesAsync();
return new WorkOrderMediaFileDto
{
Id = -2,
Category = category,
Url = fileUrl,
IsLegacy = true
};
}
var attachment = new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl,
Category = category,
CreatedDate = DateTime.UtcNow,
createdby = actorId
};
_context.workOrderAttachments.Add(attachment);
await _context.SaveChangesAsync();
return new WorkOrderMediaFileDto
{
Id = attachment.Id,
Category = category,
Url = fileUrl,
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
IsLegacy = false
};
}
public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId)
{
if (mediaId <= 0)
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint.");
if (!await _detailData.ExistsAsync(workOrderId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
var attachment = await _context.workOrderAttachments
.FirstOrDefaultAsync(a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true);
if (attachment == null)
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
attachment.IsDeleted = true;
attachment.DeletionTime = DateTime.UtcNow;
attachment.DeleterUserId = actorId;
await _context.SaveChangesAsync();
}
}
}

View file

@ -0,0 +1,81 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderOpsHealthService : IWorkOrderOpsHealthService
{
private readonly ApplicationDbContext _db;
private readonly WorkOrderJobRunStateAccessor _runState;
private readonly WorkOrderOpsHealthOptions _options;
public WorkOrderOpsHealthService(
ApplicationDbContext db,
WorkOrderJobRunStateAccessor runState,
IOptions<WorkOrderOpsHealthOptions> options)
{
_db = db;
_runState = runState;
_options = options.Value;
}
public async Task<WorkOrderOpsHealthDto> GetHealthAsync(CancellationToken cancellationToken = default)
{
var since = DateTime.UtcNow.AddHours(-24);
var syncRejected = await _db.WorkOrderAuditLogs
.CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken);
var fieldLocks = await _db.WorkOrderFieldLocks.CountAsync(cancellationToken);
var state = _runState.State;
DateTime? ledgerWeekRolled = null;
if (await _db.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken))
{
ledgerWeekRolled = await _db.WorkOrderWeekRolledLedgers
.MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken);
}
return new WorkOrderOpsHealthDto
{
LastWeekRolledRunUtc = state.LastWeekRolledRunUtc ?? ledgerWeekRolled,
LastPastDueCacheRunUtc = state.LastPastDueCacheRunUtc,
LastWeekRolledError = state.LastWeekRolledError,
LastPastDueCacheError = state.LastPastDueCacheError,
SyncRejectedLast24h = syncRejected,
FieldLockCount = fieldLocks,
SyncEnabled = _options.SyncEnabled,
IngestEnabled = _options.IngestEnabled,
LegacyDeprecationEnabled = _options.LegacyDeprecationEnabled,
LegacySunsetDate = _options.LegacySunsetDate,
CheckedAtUtc = DateTime.UtcNow
};
}
}
/// <summary>Options bridged from API configuration for health snapshots.</summary>
public class WorkOrderOpsHealthOptions
{
public bool SyncEnabled { get; set; } = true;
public bool IngestEnabled { get; set; } = true;
public bool LegacyDeprecationEnabled { get; set; }
public string? LegacySunsetDate { get; set; }
}
/// <summary>Shared in-memory job run timestamps (API hosted services + ops health).</summary>
public class WorkOrderJobRunStateAccessor
{
public WorkOrderJobRunState State { get; } = new();
}
public class WorkOrderJobRunState
{
public DateTime? LastWeekRolledRunUtc { get; set; }
public DateTime? LastPastDueCacheRunUtc { get; set; }
public string? LastWeekRolledError { get; set; }
public string? LastPastDueCacheError { get; set; }
}
}

View file

@ -9,6 +9,7 @@ namespace SeaHaven.Services.Interfaces
Task<IEnumerable<LocationDTO>> GetAllLocationsAsync();
Task<IEnumerable<LocationDTO>> GetLocationsByAccountIdAsync(int accountId);
Task<PagedResult<LocationDTO>> GetLocationsPagedAsync(int page, int pageSize, string? search = null);
Task<IReadOnlyList<SiteOptionDTO>> GetSiteOptionsAsync(string? search = null);
Task<LocationDTO> CreateLocationAsync(CreateLocationDTO dto, string userId);
Task<LocationDTO> UpdateLocationAsync(int id, UpdateLocationDTO dto, string userId);
Task DeleteLocationAsync(int id, string userId);

View file

@ -0,0 +1,11 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderCommentService
{
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
}
}

View file

@ -0,0 +1,19 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderCompletionService
{
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId,
WorkOrderCompletionDocUploadDto request,
string fileUrl,
string? actorId);
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
Task DeleteTemplateAsync(int id);
}
}

View file

@ -0,0 +1,10 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderDetailService
{
Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50);
}
}

View file

@ -0,0 +1,12 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderMediaService
{
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId);
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
}
}

View file

@ -0,0 +1,16 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderIngestService
{
Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
IReadOnlyList<WorkOrderIngestPayloadDto> items,
CancellationToken cancellationToken = default);
}
public interface IWorkOrderOpsHealthService
{
Task<WorkOrderOpsHealthDto> GetHealthAsync(CancellationToken cancellationToken = default);
}
}

View file

@ -30,6 +30,34 @@ namespace SeaHaven.Services.Validation
.MaximumLength(128)
.When(x => !string.IsNullOrEmpty(x.Trade));
RuleFor(x => x.PrimaryService)
.MaximumLength(128)
.When(x => !string.IsNullOrEmpty(x.PrimaryService));
RuleFor(x => x.ServiceNotes)
.MaximumLength(4000)
.When(x => !string.IsNullOrEmpty(x.ServiceNotes));
RuleFor(x => x.PocName)
.MaximumLength(200)
.When(x => !string.IsNullOrEmpty(x.PocName));
RuleFor(x => x.PocPhone)
.MaximumLength(50)
.When(x => !string.IsNullOrEmpty(x.PocPhone));
RuleFor(x => x.PocNotes)
.MaximumLength(2000)
.When(x => !string.IsNullOrEmpty(x.PocNotes));
RuleFor(x => x.TechPhone)
.MaximumLength(50)
.When(x => !string.IsNullOrEmpty(x.TechPhone));
RuleFor(x => x.VendorNotes)
.MaximumLength(2000)
.When(x => !string.IsNullOrEmpty(x.VendorNotes));
RuleFor(x => x.LocationId)
.GreaterThan(0)
.When(x => x.LocationId.HasValue);
@ -41,6 +69,26 @@ namespace SeaHaven.Services.Validation
RuleFor(x => x.PocContactId)
.GreaterThan(0)
.When(x => x.PocContactId.HasValue);
RuleFor(x => x)
.Must(HasPrimaryWhenExtras)
.WithMessage("primaryService (or trade) is required when extraServices is provided.")
.WithName("extraServices");
RuleForEach(x => x.ExtraServices)
.MaximumLength(128)
.When(x => x.ExtraServices != null);
}
private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request)
{
var hasExtras = request.ExtraServices != null
&& request.ExtraServices.Any(s => !string.IsNullOrWhiteSpace(s));
if (!hasExtras)
return true;
return !string.IsNullOrWhiteSpace(request.PrimaryService)
|| !string.IsNullOrWhiteSpace(request.Trade);
}
}
}

View file

@ -180,4 +180,173 @@ public class WorkOrderBoardCreateServiceTests
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
Assert.True(result.Id > 0);
}
[Fact]
public async Task Create_StructuredServiceNotes_PersistsAndReturnsPm()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
PrimaryService = "HVAC PM",
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
ServiceNotes = "Unit on roof"
}, "actor-1");
Assert.Equal("HVAC PM", result.Pm);
Assert.Equal("Unit on roof", result.ServiceNotes);
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.Equal("HVAC PM", wo.Trade);
Assert.Equal("Unit on roof", wo.ServiceNotes);
Assert.Contains("Filter change", wo.ExtraServices);
Assert.Contains("Coil clean", wo.ExtraServices);
Assert.Equal(1, System.Text.Json.JsonSerializer.Deserialize<List<string>>(wo.ExtraServices!)!
.Count(s => s == "Filter change"));
}
[Fact]
public async Task Create_PrimaryServiceTakesPrecedenceOverTrade()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "CHI",
Trade = "Legacy Trade",
PrimaryService = "Plumbing"
}, "actor-1");
Assert.Equal("Plumbing", result.Pm);
}
[Fact]
public async Task Create_ExtraServicesWithoutPrimary_ThrowsValidation()
{
var (_, service) = CreateSut();
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
ExtraServices = new List<string> { "Filter change" }
}, "actor-1"));
}
[Fact]
public async Task Create_PocFreeText_WithoutContact_AppearsOnBoardRow()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5",
PocName = "Jane Site Lead",
PocPhone = "+1 555-0100",
PocNotes = "Call 30 min before"
}, "actor-1");
Assert.Equal("Jane Site Lead", result.PocName);
Assert.Equal("+1 555-0100", result.PocPhone);
Assert.Equal("Call 30 min before", result.PocNotes);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.Equal("Jane Site Lead", wo.PocName);
Assert.Empty(context.WorkOrderContacts);
}
[Fact]
public async Task Create_WithoutVendor_PersistsTechPhoneAndVendorNotesOnWorkOrder()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
TechPhone = "+1 555-0199",
VendorNotes = "Gate code 4421"
}, "actor-1");
Assert.Equal("+1 555-0199", result.TechPhone);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.Equal("+1 555-0199", wo.TechPhone);
Assert.Equal("Gate code 4421", wo.VendorNotes);
Assert.Null(wo.PrimaryDispatchId);
}
[Fact]
public async Task Create_WithVendor_PersistsTechPhoneAndVendorNotesOnDispatch()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 55, CompanyName = "Acme", Phone = "+1 555-0000" });
await context.SaveChangesAsync();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
VendorId = 55,
ApptDate = new DateTime(2026, 7, 18),
TechPhone = "+1 555-0199",
VendorNotes = "Park behind dock"
}, "actor-1");
Assert.Equal("+1 555-0199", result.TechPhone);
Assert.Equal(55, result.VendorId);
var wo = await context.workOrders.Include(w => w.PrimaryDispatch).SingleAsync(w => w.Id == result.Id);
Assert.Null(wo.TechPhone);
Assert.Null(wo.VendorNotes);
Assert.NotNull(wo.PrimaryDispatch);
Assert.Equal("+1 555-0199", wo.PrimaryDispatch!.TechPhone);
Assert.Equal("Park behind dock", wo.PrimaryDispatch.VendorNotes);
}
[Fact]
public async Task Create_LegacyTradeAndDescription_StillWorks()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
Trade = "HVAC",
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
}, "actor-1");
Assert.Equal("HVAC", result.Pm);
Assert.Null(result.PocName);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.Equal("HVAC", wo.Trade);
Assert.Contains("Vendor notes:", wo.Description);
Assert.Null(wo.PocName);
Assert.Null(wo.ExtraServices);
Assert.Null(wo.ServiceNotes);
}
[Fact]
public async Task Create_UnknownVendorId_Throws()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
VendorId = 999
}, "actor-1"));
Assert.Equal("VendorNotFound", ex.Code);
}
}

View file

@ -107,6 +107,33 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("PastDueStatusBlocked", ex.Code);
}
[Fact]
public async Task PatchField_RejectsOverdueAsWorkOrderType()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Reactive,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PatchField_BlocksEditWhenCanceled()
{
@ -182,6 +209,303 @@ public class WorkOrderBoardUpdateServiceTests
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
}
[Fact]
public async Task PatchField_NormalizesWoNumber()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000009999",
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "12345",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("00000012345", result.WoNumber);
}
[Fact]
public async Task PatchField_RejectsDuplicateWoNumber()
{
var (context, service) = CreateSut();
context.workOrders.AddRange(
new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } },
new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } });
await context.SaveChangesAsync();
var wo = await context.workOrders.FindAsync(1);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "22222",
WorkOrderVersion = ToVersion(wo!)
}, "actor-1"));
Assert.Equal("DuplicateWoNumber", ex.Code);
}
[Fact]
public async Task PatchField_UpdatesPmTrade()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "HVAC",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Pm,
Value = "Plumbing",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("Plumbing", result.Pm);
Assert.Equal("Plumbing", (await context.workOrders.FindAsync(1))!.Trade);
}
[Fact]
public async Task PatchField_UpdatesServiceNotesAndReturnsOnBoardRow()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "Backflow Preventers",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ServiceNotes,
Value = "service notes",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("service notes", result.ServiceNotes);
Assert.Equal("service notes", (await context.workOrders.FindAsync(1))!.ServiceNotes);
}
[Fact]
public async Task PatchField_UpdatesExtraServices_WhenPrimaryExists()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "Backflow Preventers",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ExtraServices,
Value = "[\"Filter change\",\"Coil clean\"]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
Assert.Contains("Filter change", (await context.workOrders.FindAsync(1))!.ExtraServices);
}
[Fact]
public async Task PatchField_ExtraServicesWithoutPrimary_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ExtraServices,
Value = "[\"Filter change\"]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_SetsFlagColor_CanonicalUppercase()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#ef4444",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("#EF4444", result.FlagColor);
Assert.Equal("#EF4444", (await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_ClearsFlagColor_WithNullOrEmpty()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
FlagColor = "#EF4444",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(cleared.FlagColor);
Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_ClearsFlagColor_WithNullValue()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
FlagColor = "#3B82F6",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(cleared.FlagColor);
Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_FlagColorOutsidePalette_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#000000",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_FlagColorMalformed_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "not-a-color",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_FlagColor_DoesNotRequireDispatchVersion()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Dispatches.Add(new Dispatch
{
Id = 10,
WorkOrderId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
});
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#8B5CF6",
WorkOrderVersion = ToVersion(wo)
// no DispatchVersion
}, "actor-1");
Assert.Equal("#8B5CF6", result.FlagColor);
}
[Fact]
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
{
@ -232,51 +556,6 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task PatchField_NormalizesWoNumber()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000009999",
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "12345",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("00000012345", result.WoNumber);
}
[Fact]
public async Task PatchField_RejectsDuplicateWoNumber()
{
var (context, service) = CreateSut();
context.workOrders.AddRange(
new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } },
new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } });
await context.SaveChangesAsync();
var wo = await context.workOrders.FindAsync(1);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "22222",
WorkOrderVersion = ToVersion(wo!)
}, "actor-1"));
Assert.Equal("DuplicateWoNumber", ex.Code);
}
[Fact]
public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo()
{

View file

@ -0,0 +1,406 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderDocStatusPatchTests
{
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var service = new WorkOrderBoardUpdateService(context, boardData, audit);
return (context, service);
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
[Theory]
[InlineData("Yes", DocStatus.Yes)]
[InlineData("No", DocStatus.No)]
[InlineData("NotNeeded", DocStatus.NotNeeded)]
public async Task PatchField_DocStatus_SetsValue(string value, DocStatus expected)
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DocStatus,
Value = value,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(expected, result.DocStatus);
var audit = await context.WorkOrderAuditLogs.SingleAsync();
Assert.Equal("DocStatus", audit.FieldName);
}
[Fact]
public async Task PatchField_DocStatus_ReadOnlyWorkOrder_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Canceled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.DocStatus,
Value = "Yes",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("CanceledReadOnly", ex.Code);
}
}
public class WorkOrderDetailServiceTests
{
private static (ApplicationDbContext Context, WorkOrderDetailService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var detailData = new WorkOrderDetailDataService(context);
var templateData = new CompletionDocTemplateDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderDetailService(boardService, detailData, templateData, userData);
return (context, service);
}
[Fact]
public async Task GetDetail_ReturnsDerivedFieldsAndFeAuditMapping()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(-2),
Trade = "HVAC PM",
WorkOrderType = WorkOrderType.PM,
DocStatus = DocStatus.No,
Description = "Leak",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Name = "HVAC PM Completion",
ServiceKey = "HVAC PM",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/hvac.pdf",
IsActive = true
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
EventType = "System",
Action = "WeekRolled",
FieldName = "CarriedOver",
OldValue = "0",
NewValue = "1",
CreatedAt = DateTime.UtcNow
});
context.Comments.Add(new Comments
{
WorkerOrderId = 1,
UserId = "user-1",
Commenttext = "Called vendor",
CreatedDate = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc)
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1);
Assert.NotNull(detail);
Assert.True(detail!.Info.IsPastDue);
Assert.Equal("HVAC PM", detail.Info.Trade);
Assert.Equal("Leak", detail.Info.Description);
Assert.NotNull(detail.Completion.Template);
Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name);
Assert.Single(detail.Comments);
Assert.Equal("user-1", detail.Comments[0].AuthorId);
Assert.Equal("Called vendor", detail.Comments[0].Text);
Assert.Single(detail.Audit);
Assert.Equal("system", detail.Audit[0].Type);
Assert.Equal("WeekRolled", detail.Audit[0].Action);
}
}
public class WorkOrderCompletionServiceTests
{
private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var templateData = new CompletionDocTemplateDataService(context);
var detailData = new WorkOrderDetailDataService(context);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var service = new WorkOrderCompletionService(context, templateData, detailData, audit);
return (context, service);
}
[Fact]
public async Task UploadCompletionDoc_SetsDocStatusYesAndSignOff()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
Trade = "HVAC PM",
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto
{
SignOffName = "Jane Dispatcher",
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!)
},
"https://example.com/signed.pdf",
"actor-1");
Assert.Equal(DocStatus.Yes, result.DocStatus);
Assert.Equal("https://example.com/signed.pdf", result.SignOffAttachment);
Assert.Equal("Jane Dispatcher", result.SignOffName);
var updated = await context.workOrders.FindAsync(1);
Assert.Equal(DocStatus.Yes, updated!.DocStatus);
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
}
[Fact]
public async Task UploadCompletionDoc_SecondUpload_DoesNotDuplicateFieldLocks()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var version = Convert.ToBase64String(wo.RowVersion!);
await service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version },
"https://example.com/first.pdf",
"actor-1");
// Simulate a new request scope: clear local tracker then reload
context.ChangeTracker.Clear();
var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1);
// InMemory does not bump rowversion; keep same bytes for the second call
reloaded.RowVersion ??= new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
await service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!)
},
"https://example.com/second.pdf",
"actor-1");
var locks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Equal(1, locks.Count(l => l.FieldName == "SignOffAttachment"));
Assert.Equal(1, locks.Count(l => l.FieldName == "DocStatus"));
Assert.Equal("https://example.com/second.pdf", reloaded.SignOffAttachment);
}
[Fact]
public async Task UploadCompletionDoc_MissingVersion_Throws()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto(),
"https://example.com/signed.pdf",
"actor-1"));
Assert.Equal("WorkOrderVersionRequired", ex.Code);
}
[Fact]
public async Task UploadCompletionDoc_ReadOnly_Throws()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Completed
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UploadCompletionDocAsync(
1,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
},
"https://example.com/signed.pdf",
"actor-1"));
Assert.Equal("ReadOnly", ex.Code);
}
}
public class WorkOrderCommentServiceTests
{
[Fact]
public async Task AddComment_ReturnsFeSchema()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var service = new WorkOrderCommentService(detailData, commentData);
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
Assert.Equal("user-abc", result.AuthorId);
Assert.Equal("Note", result.Text);
Assert.False(string.IsNullOrWhiteSpace(result.Time));
}
}
public class WorkOrderMediaServiceTests
{
[Fact]
public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder
{
Id = 1,
BeforPhotoAttachment = "https://example.com/before.jpg",
AfterPhotoAttachment = "https://example.com/after.jpg"
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = 1,
Attachments = "https://example.com/extra.pdf",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var service = new WorkOrderMediaService(context, detailData);
var media = await service.GetMediaAsync(1);
Assert.NotNull(media);
Assert.Equal(3, media!.Count);
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Before);
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.After);
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra);
}
[Fact]
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Completed
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/extra.pdf",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context));
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.DeleteMediaAsync(1, 10, "actor-1"));
Assert.Equal("ReadOnly", ex.Code);
}
[Fact]
public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled
});
await context.SaveChangesAsync();
var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context));
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1"));
Assert.Equal("UseCompletionDocEndpoint", ex.Code);
}
}

View file

@ -0,0 +1,241 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderPhase7CoexistenceTests
{
[Fact]
public void IngestFieldMapper_MapsDynamoStatus()
{
Assert.Equal("Open", WorkOrderIngestFieldMapper.MapStatus("new"));
Assert.Equal("In Progress", WorkOrderIngestFieldMapper.MapStatus("in_progress"));
Assert.Equal("Done", WorkOrderIngestFieldMapper.MapStatus("completed"));
}
[Fact]
public async Task IngestService_CreatesThenUpdates_IdempotentByExternalId()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
var payload = new WorkOrderIngestPayloadDto
{
ExternalWorkOrderId = "ext-100",
Description = "Initial",
WoStatus = "new",
Severity = "2",
SiteCode = "BK1"
};
var create = await ingest.UpsertBatchAsync(new[] { payload });
Assert.Equal(1, create.Created);
Assert.Equal(0, create.Updated);
payload.Description = "Updated via ingest";
var update = await ingest.UpsertBatchAsync(new[] { payload });
Assert.Equal(0, update.Created);
Assert.Equal(1, update.Updated);
var wo = await context.workOrders.SingleAsync();
Assert.Equal("Updated via ingest", wo.Description);
Assert.Equal("ext-100", wo.ExternalWorkOrderId);
}
[Fact]
public async Task IngestRespectsFieldLock_AfterShocBoardCreate()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var createService = new WorkOrderBoardCreateService(
context, boardData, boardService, audit, new WorkOrderBoardCreateValidation());
var policy = new SyncFieldMergePolicy(fieldLocks);
var ingest = new WorkOrderIngestService(context, policy, fieldLocks, audit);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
}, "dispatcher-1");
var wo = await context.workOrders.SingleAsync();
wo.ExternalWorkOrderId = "ext-shoc-1";
await context.SaveChangesAsync();
await ingest.UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto
{
ExternalWorkOrderId = "ext-shoc-1",
SiteCode = "SYNC-SITE"
}
});
Assert.Equal("BK5", wo.SiteCode);
Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l => l.Action == "SyncRejected"));
}
[Fact]
public async Task IngestRespectsLocationIdFieldLock()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
context.Locations.Add(new Locations { Id = 1, Name = "BK1", Status = "Active" });
context.Locations.Add(new Locations { Id = 2, Name = "BK2", Status = "Active" });
context.workOrders.Add(new WorkOrder
{
Id = 10,
ExternalWorkOrderId = "ext-loc-1",
LocationId = 1,
SiteCode = "BK1",
istemplate = false
});
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 10,
FieldName = "LocationId",
LockedAt = DateTime.UtcNow,
LockedByUserId = "dispatcher-1"
});
await context.SaveChangesAsync();
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
await ingest.UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto
{
ExternalWorkOrderId = "ext-loc-1",
SiteCode = "BK2",
Building = "BK2"
}
});
var wo = await context.workOrders.SingleAsync(w => w.Id == 10);
Assert.Equal(1, wo.LocationId);
Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l =>
l.Action == "SyncRejected" && l.FieldName == "LocationId"));
}
[Fact]
public async Task IngestBatch_SkipsItemsWithoutExternalId()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
var result = await ingest.UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" },
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "ext-ok", Description = "good" }
});
Assert.Equal(1, result.Created);
Assert.Equal(1, result.Skipped);
Assert.Equal(1, result.Results.Count);
Assert.Equal("ext-ok", result.Results[0].ExternalWorkOrderId);
}
[Fact]
public async Task OpsHealth_ReturnsCoexistenceMetrics()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 1,
FieldName = "SiteCode",
LockedAt = DateTime.UtcNow,
LockedByUserId = "u1"
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
Action = "SyncRejected",
FieldName = "Description",
CreatedAt = DateTime.UtcNow.AddHours(-1)
});
await context.SaveChangesAsync();
var runState = new WorkOrderJobRunStateAccessor
{
State = { LastWeekRolledRunUtc = DateTime.UtcNow.AddDays(-1) }
};
var healthOptions = Options.Create(new WorkOrderOpsHealthOptions
{
SyncEnabled = true,
IngestEnabled = true,
LegacyDeprecationEnabled = true,
LegacySunsetDate = "2026-12-31"
});
var service = new WorkOrderOpsHealthService(context, runState, healthOptions);
var health = await service.GetHealthAsync();
Assert.Equal(1, health.FieldLockCount);
Assert.Equal(1, health.SyncRejectedLast24h);
Assert.True(health.SyncEnabled);
Assert.True(health.IngestEnabled);
Assert.Equal("2026-12-31", health.LegacySunsetDate);
}
[Fact]
public async Task BoardAndLegacyEndpoints_CanCoexist_InSameDatabase()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder
{
WorkerOrderTitle = "Legacy row",
Status = "Open",
InternalWONumber = "10000001",
istemplate = false
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var count = await context.workOrders.CountAsync();
Assert.Equal(1, count);
Assert.NotNull(boardService);
}
}

View file

@ -22,14 +22,22 @@ namespace SeaHavenIndustries.Data.Services
private UserManager<ApplicationUser> _userManager;
private UploadFileHp _uploadFileHelper;
private readonly SendMessage _sendemail;
private readonly BlazorWorkOrderSunsetGuard _sunsetGuard;
public WorkorderService(SendMessage sendemail, ApplicationDbContext context, IHttpContextAccessor httpContextAccessor, UserManager<ApplicationUser> userManager, UploadFileHp uploadFileHelper)
public WorkorderService(
SendMessage sendemail,
ApplicationDbContext context,
IHttpContextAccessor httpContextAccessor,
UserManager<ApplicationUser> userManager,
UploadFileHp uploadFileHelper,
BlazorWorkOrderSunsetGuard sunsetGuard)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
_userManager = userManager;
_uploadFileHelper = uploadFileHelper;
_sendemail = sendemail;
_sunsetGuard = sunsetGuard;
}
public async Task<List<WorkOrder>> Getworkorder()
{
@ -178,6 +186,7 @@ namespace SeaHavenIndustries.Data.Services
}
public async Task<string> Addworkorder(WorkOrder model, List<FileUploadViewModel> files)
{
_sunsetGuard.ThrowIfMutationsBlocked();
try
{
_context.ChangeTracker.Clear();
@ -425,6 +434,7 @@ namespace SeaHavenIndustries.Data.Services
}
public async Task<string> Deleteworkorder(int id)
{
_sunsetGuard.ThrowIfMutationsBlocked();
_context.ChangeTracker.Clear();
try

View file

@ -0,0 +1,26 @@
using Microsoft.Extensions.Options;
using SeaHavenIndustries.Options;
namespace SeaHavenIndustries.Helper
{
public class BlazorWorkOrderSunsetGuard
{
private readonly BlazorWorkOrderSunsetOptions _options;
public BlazorWorkOrderSunsetGuard(IOptions<BlazorWorkOrderSunsetOptions> options)
{
_options = options.Value;
}
public bool IsSunsetEnabled => _options.Enabled;
public string ShocBaseUrl => _options.ShocBaseUrl.TrimEnd('/');
public void ThrowIfMutationsBlocked()
{
if (_options.Enabled)
throw new InvalidOperationException(
"Work Orders module has moved to SHOC. Editing via Blazor is disabled.");
}
}
}

View file

@ -0,0 +1,11 @@
namespace SeaHavenIndustries.Options
{
public class BlazorWorkOrderSunsetOptions
{
public const string SectionName = "BlazorWorkOrderSunset";
public bool Enabled { get; set; }
public string ShocBaseUrl { get; set; } = "https://shoc.seahaven.com";
}
}

View file

@ -77,6 +77,9 @@ builder.Services.AddScoped<UploadFileHp>();
builder.Services.AddScoped<NorthwindService>();
builder.Services.AddScoped<SendMessage>();
builder.Services.AddScoped<PdfService>();
builder.Services.Configure<SeaHavenIndustries.Options.BlazorWorkOrderSunsetOptions>(
builder.Configuration.GetSection(SeaHavenIndustries.Options.BlazorWorkOrderSunsetOptions.SectionName));
builder.Services.AddSingleton<BlazorWorkOrderSunsetGuard>();
builder.Services.AddSession(options =>
{

View file

@ -1,10 +1,5 @@
{
"ConnectionStrings": {
//"DefaultConnection": "Server=.;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true",
//"DefaultConnection": "Server=.\\SqlExpress;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true",
// Provide the real value via environment variable ConnectionStrings__DefaultConnection,
// user-secrets, or appsettings.Development.json (which is gitignored). Do NOT commit credentials.
"DefaultConnection": "${CONNECTION_STRING}",
"excelconnection": "Provider=Microsoft.ACE.OLEDB.12.0;Data Source={0};Extended Properties='Excel 8.0;HDR=YES'"
},
@ -15,12 +10,14 @@
}
},
"SendGrid": {
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
"ApiKey": "${SENDGRID_API_KEY}"
},
"GoogleMaps": {
// Provide the real value via environment variable GoogleMaps__ApiKey or user-secrets.
"ApiKey": "${GOOGLE_MAPS_API_KEY}"
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"BlazorWorkOrderSunset": {
"Enabled": false,
"ShocBaseUrl": "https://shoc.seahaven.com"
}
}