diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..083ec1a
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,3 @@
+# All changes require an approving review from the internal-dev team.
+# Enforced via the org "main branch protection" ruleset (require_code_owner_review).
+* @Sea-Haven-Industries/internal-dev
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..24167e5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,11 @@
+name: CI
+on:
+ pull_request:
+ branches: [main, dev]
+jobs:
+ ci:
+ uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@main
+ with:
+ dotnet-version: "8.0.x"
+ solution: "SeaHavenIndustries.sln"
+ run-tests: true
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
new file mode 100644
index 0000000..4af423a
--- /dev/null
+++ b/.github/workflows/labeler.yml
@@ -0,0 +1,11 @@
+name: Labeler
+on:
+ pull_request:
+ branches: [main]
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+jobs:
+ label:
+ uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@main
diff --git a/Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj b/Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj
new file mode 100644
index 0000000..01e908a
--- /dev/null
+++ b/Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs b/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs
new file mode 100644
index 0000000..571b606
--- /dev/null
+++ b/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs
@@ -0,0 +1,206 @@
+using System.Collections;
+using Api.SeaHavenIndustries.Controllers;
+using Api.SeaHavenIndustries.DTOs;
+using Data.SeaHavenIndustries;
+using FluentAssertions;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Moq;
+using Xunit;
+
+namespace Api.SeaHavenIndustries.Tests;
+
+public class CalendarControllerTests
+{
+ private static ApplicationDbContext NewContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
+ .Options;
+ return new ApplicationDbContext(options);
+ }
+
+ private static UserManager MockUserManager()
+ {
+ var store = new Mock>();
+ return new Mock>(
+ store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
+ }
+
+ private static CalendarController NewController(ApplicationDbContext ctx) =>
+ new(MockUserManager(), ctx);
+
+ private static EditEvent_DTO ValidDto(int? id = null, string title = "Standup") => new()
+ {
+ Id = id,
+ Title = title,
+ ReminderMinutes = 15,
+ EventColor = "primary",
+ StartDate = new DateTime(2026, 6, 1),
+ StartTime = "08:00 AM",
+ EndDate = new DateTime(2026, 6, 1),
+ EndTime = "08:30 AM"
+ };
+
+ private static Events SeedEvent(ApplicationDbContext ctx, string title, DateTime startDate, bool? isDeleted = false)
+ {
+ var ev = new Events
+ {
+ Title = title,
+ EventColor = "primary",
+ ReminderMinutes = 15,
+ StartDate = startDate,
+ EndDate = startDate,
+ IsDeleted = isDeleted,
+ CreatedDate = DateTime.Now
+ };
+ ctx.Events.Add(ev);
+ ctx.SaveChanges();
+ return ev;
+ }
+
+ private static object Prop(object source, string name) =>
+ source.GetType().GetProperty(name)!.GetValue(source)!;
+
+ [Fact]
+ public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk()
+ {
+ using var ctx = NewContext();
+ var result = await NewController(ctx).AddEvent(ValidDto());
+
+ var ok = result.Should().BeOfType().Subject;
+ ok.Value.Should().BeOfType().Which.Status.Should().Be("200");
+
+ var saved = ctx.Events.Single();
+ saved.Title.Should().Be("Standup");
+ saved.IsDeleted.Should().Be(false);
+ saved.CreatedDate.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task EditEvent_WhenIdMissing_ReturnsBadRequest()
+ {
+ using var ctx = NewContext();
+ var result = await NewController(ctx).EditEvent(ValidDto(id: null));
+
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task EditEvent_WhenEventNotFound_ReturnsNotFound()
+ {
+ using var ctx = NewContext();
+ var result = await NewController(ctx).EditEvent(ValidDto(id: 999));
+
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified()
+ {
+ using var ctx = NewContext();
+ var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1));
+
+ var result = await NewController(ctx).EditEvent(ValidDto(id: existing.Id, title: "New"));
+
+ result.Should().BeOfType();
+ var updated = ctx.Events.Single();
+ updated.Title.Should().Be("New");
+ updated.LastModificationTime.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task GetEventById_WhenExists_ReturnsEventDto()
+ {
+ using var ctx = NewContext();
+ var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1));
+
+ var result = await NewController(ctx).GetEventById(existing.Id);
+
+ var ok = result.Should().BeOfType().Subject;
+ var dto = ok.Value.Should().BeOfType().Subject.Data as Event_DTO;
+ dto!.Title.Should().Be("Review");
+ }
+
+ [Fact]
+ public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound()
+ {
+ using var ctx = NewContext();
+ var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true);
+
+ var result = await NewController(ctx).GetEventById(deleted.Id);
+
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow()
+ {
+ using var ctx = NewContext();
+ var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1));
+
+ var result = await NewController(ctx).DeleteEvent(existing.Id);
+
+ result.Should().BeOfType();
+ var row = ctx.Events.Single();
+ row.IsDeleted.Should().Be(true);
+ row.DeletionTime.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task DeleteEvent_WhenNotFound_ReturnsNotFound()
+ {
+ using var ctx = NewContext();
+ var result = await NewController(ctx).DeleteEvent(12345);
+
+ result.Should().BeOfType();
+ }
+
+ [Fact]
+ public void GetEventList_ExcludesDeletedAndCountsAfterFilter()
+ {
+ using var ctx = NewContext();
+ SeedEvent(ctx, "Alpha meeting", new DateTime(2026, 6, 1));
+ SeedEvent(ctx, "Beta meeting", new DateTime(2026, 6, 2));
+ SeedEvent(ctx, "Alpha review", new DateTime(2026, 6, 3));
+ SeedEvent(ctx, "Alpha archived", new DateTime(2026, 6, 4), isDeleted: true);
+
+ var result = NewController(ctx).GetEventList(search: "alpha", page: 1, pageSize: 10);
+
+ var ok = result.Should().BeOfType().Subject;
+ var data = ok.Value.Should().BeOfType().Subject.Data!;
+ ((int)Prop(data, "totalCount")).Should().Be(2);
+ }
+
+ [Fact]
+ public void GetEventList_OrdersByStartDateDescending()
+ {
+ using var ctx = NewContext();
+ SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1));
+ SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10));
+ SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5));
+
+ var result = NewController(ctx).GetEventList(page: 1, pageSize: 10);
+
+ var data = (result as OkObjectResult)!.Value.As().Data!;
+ var rows = ((IEnumerable)Prop(data, "rows")).Cast().ToList();
+ rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest");
+ }
+
+ [Fact]
+ public void GetEventsForCalendar_ReturnsOnlyEventsOverlappingRange()
+ {
+ using var ctx = NewContext();
+ SeedEvent(ctx, "InRange", new DateTime(2026, 6, 15));
+ SeedEvent(ctx, "BeforeRange", new DateTime(2026, 5, 1));
+ SeedEvent(ctx, "AfterRange", new DateTime(2026, 7, 1));
+
+ var result = NewController(ctx).GetEventsForCalendar(
+ start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30));
+
+ var ok = result.Should().BeOfType().Subject;
+ var events = ok.Value.As().Data as IEnumerable;
+ events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
+ }
+}
diff --git a/Api.SeaHavenIndustries/Controllers/CalendarController.cs b/Api.SeaHavenIndustries/Controllers/CalendarController.cs
new file mode 100644
index 0000000..2d7f85d
--- /dev/null
+++ b/Api.SeaHavenIndustries/Controllers/CalendarController.cs
@@ -0,0 +1,306 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Api.SeaHavenIndustries.DTOs;
+using Data.SeaHavenIndustries;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Api.SeaHavenIndustries.Controllers
+{
+ [Authorize]
+ [ApiController]
+ [Route("api/Calendar")]
+ public class CalendarController : Controller
+ {
+ private readonly UserManager _userManager;
+ private readonly ApplicationDbContext _db;
+
+ public CalendarController(UserManager userManager, ApplicationDbContext db)
+ {
+ _userManager = userManager;
+ _db = db;
+ }
+
+ // ===================================================================================
+ // CREATE EVENT
+ // ===================================================================================
+ [HttpPost]
+ [Route("AddEvent")]
+ public async Task AddEvent(EditEvent_DTO eventDto)
+ {
+ try
+ {
+ var model = new Events
+ {
+ Title = eventDto.Title,
+ Description = eventDto.Description,
+ Location = eventDto.Location,
+ RecordID = eventDto.RecordID,
+ RecordName = eventDto.RecordName,
+ RecordTargetType = eventDto.RecordTargetType,
+ ReminderMinutes = eventDto.ReminderMinutes,
+ EventColor = eventDto.EventColor,
+ StartDate = eventDto.StartDate,
+ StartTime = eventDto.StartTime,
+ EndDate = eventDto.EndDate,
+ EndTime = eventDto.EndTime,
+ AllDay = eventDto.AllDay,
+ CreatedDate = DateTime.Now,
+ IsDeleted = false
+ };
+
+ _db.Events.Add(model);
+ await _db.SaveChangesAsync();
+
+ return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" });
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new Response { Status = "Error", Message = ex.Message });
+ }
+ }
+
+ // ===================================================================================
+ // UPDATE EVENT
+ // ===================================================================================
+ [HttpPost]
+ [Route("EditEvent")]
+ public async Task EditEvent(EditEvent_DTO eventDto)
+ {
+ try
+ {
+ if (!eventDto.Id.HasValue)
+ {
+ return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" });
+ }
+
+ var model = await _db.Events.FindAsync(eventDto.Id.Value);
+ if (model == null || model.IsDeleted == true)
+ {
+ return NotFound(new Response { Status = "Error", Message = "Event not found" });
+ }
+
+ model.Title = eventDto.Title;
+ model.Description = eventDto.Description;
+ model.Location = eventDto.Location;
+ model.RecordID = eventDto.RecordID;
+ model.RecordName = eventDto.RecordName;
+ model.RecordTargetType = eventDto.RecordTargetType;
+ model.ReminderMinutes = eventDto.ReminderMinutes;
+ model.EventColor = eventDto.EventColor;
+ model.StartDate = eventDto.StartDate;
+ model.StartTime = eventDto.StartTime;
+ model.EndDate = eventDto.EndDate;
+ model.EndTime = eventDto.EndTime;
+ model.AllDay = eventDto.AllDay;
+ model.LastModificationTime = DateTime.Now;
+
+ _db.Events.Update(model);
+ await _db.SaveChangesAsync();
+
+ return Ok(new DataResponse { Message = "Event Updated Successfully", Status = "200" });
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new Response { Status = "Error", Message = ex.Message });
+ }
+ }
+
+ // ===================================================================================
+ // GET EVENT BY ID
+ // ===================================================================================
+ [HttpGet]
+ [Route("GetEventById/{id}")]
+ public async Task GetEventById(int id)
+ {
+ try
+ {
+ var model = await _db.Events
+ .Where(e => e.Id == id && e.IsDeleted != true)
+ .FirstOrDefaultAsync();
+
+ if (model == null)
+ {
+ return NotFound(new Response { Status = "Error", Message = "Event not found" });
+ }
+
+ var dto = new Event_DTO
+ {
+ Id = model.Id,
+ Title = model.Title,
+ Description = model.Description,
+ Location = model.Location,
+ RecordID = model.RecordID,
+ RecordName = model.RecordName,
+ RecordTargetType = model.RecordTargetType,
+ ReminderMinutes = model.ReminderMinutes,
+ EventColor = model.EventColor,
+ StartDate = model.StartDate,
+ StartTime = model.StartTime,
+ EndDate = model.EndDate,
+ EndTime = model.EndTime,
+ AllDay = model.AllDay,
+ CreatedDate = model.CreatedDate,
+ createdby = model.createdby
+ };
+
+ return Ok(new DataResponse { Message = "Success", Status = "200", Data = dto });
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new Response { Status = "Error", Message = ex.Message });
+ }
+ }
+
+ // ===================================================================================
+ // DELETE EVENT
+ // ===================================================================================
+ [HttpDelete]
+ [Route("DeleteEvent/{id}")]
+ public async Task DeleteEvent(int id)
+ {
+ try
+ {
+ var model = await _db.Events.FindAsync(id);
+ if (model == null)
+ {
+ return NotFound(new Response { Status = "Error", Message = "Event not found" });
+ }
+
+ // Soft delete
+ model.IsDeleted = true;
+ model.DeletionTime = DateTime.Now;
+
+ _db.Events.Update(model);
+ await _db.SaveChangesAsync();
+
+ return Ok(new DataResponse { Message = "Event Deleted Successfully", Status = "200" });
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new Response { Status = "Error", Message = ex.Message });
+ }
+ }
+
+ // ===================================================================================
+ // GET EVENTS LIST (WITH PAGINATION AND FILTERS)
+ // ===================================================================================
+ [HttpGet]
+ [Route("GetEventList")]
+ public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10)
+ {
+ try
+ {
+ var query = _db.Events.Where(e => e.IsDeleted != true);
+
+ // Apply search filter if provided
+ if (!string.IsNullOrWhiteSpace(search))
+ {
+ var searchLower = search.ToLower();
+ query = query.Where(e =>
+ e.Title.ToLower().Contains(searchLower) ||
+ (e.Description != null && e.Description.ToLower().Contains(searchLower)) ||
+ (e.Location != null && e.Location.ToLower().Contains(searchLower))
+ );
+ }
+
+ // IMPORTANT: Calculate totalCount AFTER applying filters
+ var totalCount = query.Count();
+
+ // Apply pagination
+ var events = query
+ .OrderByDescending(e => e.StartDate)
+ .ThenByDescending(e => e.CreatedDate)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .Select(e => new Event_DTO
+ {
+ Id = e.Id,
+ Title = e.Title,
+ Description = e.Description,
+ Location = e.Location,
+ RecordID = e.RecordID,
+ RecordName = e.RecordName,
+ RecordTargetType = e.RecordTargetType,
+ ReminderMinutes = e.ReminderMinutes,
+ EventColor = e.EventColor,
+ StartDate = e.StartDate,
+ StartTime = e.StartTime,
+ EndDate = e.EndDate,
+ EndTime = e.EndTime,
+ AllDay = e.AllDay,
+ CreatedDate = e.CreatedDate,
+ createdby = e.createdby
+ })
+ .ToList();
+
+ return Ok(new DataResponse
+ {
+ Message = "Success",
+ Status = "200",
+ Data = new { rows = events, totalCount }
+ });
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new Response { Status = "Error", Message = ex.Message });
+ }
+ }
+
+ // ===================================================================================
+ // GET EVENTS FOR CALENDAR (Date Range Filter)
+ // ===================================================================================
+ [HttpGet]
+ [Route("GetEventsForCalendar")]
+ public IActionResult GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end)
+ {
+ try
+ {
+ var query = _db.Events.Where(e => e.IsDeleted != true);
+
+ // Filter by date range if provided
+ if (start.HasValue && end.HasValue)
+ {
+ query = query.Where(e =>
+ (e.StartDate >= start.Value && e.StartDate <= end.Value) ||
+ (e.EndDate >= start.Value && e.EndDate <= end.Value) ||
+ (e.StartDate <= start.Value && e.EndDate >= end.Value)
+ );
+ }
+
+ var events = query
+ .Select(e => new Event_DTO
+ {
+ Id = e.Id,
+ Title = e.Title,
+ Description = e.Description,
+ Location = e.Location,
+ RecordID = e.RecordID,
+ RecordName = e.RecordName,
+ RecordTargetType = e.RecordTargetType,
+ ReminderMinutes = e.ReminderMinutes,
+ EventColor = e.EventColor,
+ StartDate = e.StartDate,
+ StartTime = e.StartTime,
+ EndDate = e.EndDate,
+ EndTime = e.EndTime,
+ AllDay = e.AllDay,
+ CreatedDate = e.CreatedDate,
+ createdby = e.createdby
+ })
+ .ToList();
+
+ return Ok(new DataResponse { Message = "Success", Status = "200", Data = events });
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new Response { Status = "Error", Message = ex.Message });
+ }
+ }
+ }
+}
+
diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs
index 13c7751..530f5d6 100644
--- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs
+++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs
@@ -43,6 +43,7 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly Helper.SendMessage _sendMessage;
private readonly Helper.VendorPortalTokenService _vendorTokens;
private readonly IConfiguration _config;
+ private readonly IWorkOrderBoardService _workOrderBoardService;
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
public WorkOrderController(
@@ -61,6 +62,7 @@ namespace Api.SeaHavenIndustries.Controllers
Helper.SendMessage sendMessage,
Helper.VendorPortalTokenService vendorTokens,
IConfiguration config,
+ IWorkOrderBoardService workOrderBoardService,
IWorkOrderBoardUpdateService boardUpdateService)
{
_userManager = userManager;
@@ -78,10 +80,41 @@ namespace Api.SeaHavenIndustries.Controllers
_sendMessage = sendMessage;
_vendorTokens = vendorTokens;
_config = config;
+ _workOrderBoardService = workOrderBoardService;
_boardUpdateService = boardUpdateService;
}
- [Authorize]
+ [HttpGet("board")]
+ public async Task GetBoard(
+ [FromQuery] DateOnly weekStart,
+ [FromQuery] DateOnly? weekEnd = null,
+ [FromQuery] List? dispatchers = null,
+ [FromQuery] bool myWorkOrders = false,
+ [FromQuery] List? types = null,
+ [FromQuery] string? search = null)
+ {
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ var query = new WorkOrderBoardQueryDto
+ {
+ WeekStart = weekStart,
+ WeekEnd = weekEnd,
+ Dispatchers = dispatchers,
+ MyWorkOrders = myWorkOrders,
+ Types = types,
+ Search = search
+ };
+
+ var result = await _workOrderBoardService.GetBoardAsync(query, userId);
+ return Ok(result);
+ }
+
+ [HttpGet("lookups/dispatchers")]
+ public async Task GetDispatcherLookups()
+ {
+ var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
+ return Ok(result);
+ }
+
[HttpPatch("{id:int}/board")]
public async Task PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
{
diff --git a/Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs b/Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs
new file mode 100644
index 0000000..0083de5
--- /dev/null
+++ b/Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs
@@ -0,0 +1,48 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+
+namespace Api.SeaHavenIndustries.DTOs
+{
+ ///
+ /// DTO for POST/PUT requests - Create/Edit Event
+ /// Used to create or update event information
+ ///
+ public class EditEvent_DTO
+ {
+ public int? Id { get; set; } // Null for create, populated for edit
+
+ [Required(ErrorMessage = "Please enter a title")]
+ [StringLength(100, ErrorMessage = "Title cannot be longer than 100 characters")]
+ public string Title { get; set; } = null!;
+
+ public string? Description { get; set; }
+
+ [StringLength(150, ErrorMessage = "Location cannot be longer than 150 characters")]
+ public string? Location { get; set; }
+
+ public int? RecordID { get; set; }
+ public string? RecordName { get; set; }
+ public string? RecordTargetType { get; set; }
+
+ [Required(ErrorMessage = "The Remind Attendees field is required")]
+ public int ReminderMinutes { get; set; } = 15;
+
+ [Required(ErrorMessage = "The Select a Calendar Color field is required")]
+ public string EventColor { get; set; } = "primary";
+
+ [Required(ErrorMessage = "Please select a start date")]
+ public DateTime StartDate { get; set; }
+
+ [Required(ErrorMessage = "Please select a start time")]
+ public string StartTime { get; set; } = null!;
+
+ [Required(ErrorMessage = "Please select an end date")]
+ public DateTime EndDate { get; set; }
+
+ [Required(ErrorMessage = "Please select an end time")]
+ public string EndTime { get; set; } = null!;
+
+ public bool AllDay { get; set; } = false;
+ }
+}
+
diff --git a/Api.SeaHavenIndustries/DTOs/Event_DTO.cs b/Api.SeaHavenIndustries/DTOs/Event_DTO.cs
new file mode 100644
index 0000000..2543831
--- /dev/null
+++ b/Api.SeaHavenIndustries/DTOs/Event_DTO.cs
@@ -0,0 +1,33 @@
+using System;
+
+namespace Api.SeaHavenIndustries.DTOs
+{
+ ///
+ /// DTO for GET responses - Event data
+ /// Used to return event information to the client
+ ///
+ public class Event_DTO
+ {
+ public int Id { get; set; }
+ public string Title { get; set; } = null!;
+ public string? Description { get; set; }
+ public string? Location { get; set; }
+
+ public int? RecordID { get; set; }
+ public string? RecordName { get; set; }
+ public string? RecordTargetType { get; set; }
+
+ public int ReminderMinutes { get; set; }
+ public string EventColor { get; set; } = null!;
+
+ public DateTime StartDate { get; set; }
+ public string? StartTime { get; set; }
+ public DateTime EndDate { get; set; }
+ public string? EndTime { get; set; }
+ public bool AllDay { get; set; }
+
+ public DateTime? CreatedDate { get; set; }
+ public string? createdby { get; set; }
+ }
+}
+
diff --git a/Api.SeaHavenIndustries/Filters/ConcurrencyExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/ConcurrencyExceptionFilter.cs
new file mode 100644
index 0000000..6b84ee6
--- /dev/null
+++ b/Api.SeaHavenIndustries/Filters/ConcurrencyExceptionFilter.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.EntityFrameworkCore;
+
+namespace Api.SeaHavenIndustries.Filters
+{
+ public class ConcurrencyExceptionFilter : IExceptionFilter
+ {
+ public void OnException(ExceptionContext context)
+ {
+ if (context.Exception is not DbUpdateConcurrencyException) return;
+
+ context.Result = new ConflictObjectResult(new
+ {
+ status = "Conflict",
+ message = "The record was modified by another user. Refresh and retry.",
+ code = 409
+ });
+ context.ExceptionHandled = true;
+ }
+ }
+}
diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs
index a826847..6dc0e95 100644
--- a/Api.SeaHavenIndustries/Program.cs
+++ b/Api.SeaHavenIndustries/Program.cs
@@ -22,7 +22,10 @@ builder.Services.AddIdentity(options =>
.AddEntityFrameworkStores()
.AddDefaultTokenProviders();
-builder.Services.AddControllers().AddJsonOptions(options =>
+builder.Services.AddControllers(options =>
+{
+ options.Filters.Add();
+}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
});
diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs
index be52ecf..7cce9c8 100644
--- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs
+++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs
@@ -85,6 +85,7 @@ namespace Data.SeaHavenIndustries
public DbSet FollowUps { get; set; }
public DbSet PMSchedules { get; set; }
public DbSet ContactDetails { get; set; }
+ public DbSet Events { get; set; }
// Employee related DbSets
public DbSet EmployeeAddresses { get; set; }
diff --git a/Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj b/Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj
index 183423b..4ee8133 100644
--- a/Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj
+++ b/Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs
index dabc6a2..b8db7fc 100644
--- a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs
+++ b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs
@@ -1,162 +1,171 @@
-namespace Data.SeaHavenIndustries.Enums
-{
- /// Legacy enum — prefer LifecycleStatus for board operations.
- public enum WorkOrderStatus
- {
- Open = 1,
- InProgress = 2,
- Completed = 3,
- Cancelled = 4,
- OnHold = 5
- }
-
- /// 10 operational lifecycle values aligned with SHOC board (FE labels).
- public enum LifecycleStatus
- {
- Incomplete = 1,
- Pending = 2,
- Scheduled = 3,
- EnRoute = 4,
- OnSite = 5,
- InProgress = 6,
- Completed = 7,
- Rescheduled = 8,
- Canceled = 9,
- PendingQuote = 10
- }
-
- /// Board work-order types. Legacy values retained for backward compatibility.
- public enum WorkOrderType
- {
- PO = 1,
- PM = 2,
- Emergency = 3,
- Project = 4,
- Inspection = 5,
- Reactive = 6,
- AddOn = 7,
- Other = 99
- }
-
- public enum DocStatus
- {
- Yes = 1,
- No = 2,
- NotNeeded = 3
- }
-
- [Flags]
- public enum OperationalFlags
- {
- None = 0,
- PastDue = 1
- }
-
- public enum AuditEventType
- {
- Manual = 1,
- System = 2,
- Sync = 3,
- Vendor = 4
- }
-
- public enum AuditActorType
- {
- Dispatcher = 1,
- Vendor = 2,
- System = 3,
- Sync = 4
- }
-
- public enum AuditActionType
- {
- FieldChanged = 1,
- StatusChanged = 2,
- AssignmentChanged = 3,
- WeekRolled = 4,
- SyncRejected = 5,
- Create = 6,
- Delete = 7
- }
-
- public enum WorkOrderPriority
- {
- Low = 1,
- Medium = 2,
- High = 3,
- Critical = 4
- }
-
- public enum AuditAction
- {
- Create = 1,
- Edit = 2,
- Delete = 3,
- StatusChange = 4,
- AssignmentChange = 5,
- Dispatch = 6,
- DispatchUpdate = 7,
- DispatchVerify = 8
- }
-
- public enum ContactType
- {
- Primary = 1,
- Secondary = 2,
- Billing = 3,
- Technical = 4
- }
-
- public enum AssetStatus
- {
- Active = 1,
- Inactive = 2,
- UnderMaintenance = 3,
- Retired = 4
- }
-
- public enum DispatchStatus
- {
- Pending = 1,
- Assigned = 2,
- InProgress = 3,
- Completed = 4,
- Cancelled = 5
- }
-
- public enum FollowUpStatus
- {
- Open = 1,
- InProgress = 2,
- Resolved = 3,
- Closed = 4
- }
-
- public enum PMScheduleFrequency
- {
- Daily = 1,
- Weekly = 2,
- Monthly = 3,
- Quarterly = 4,
- SemiAnnual = 5,
- Annual = 6
- }
-
- public enum CommentType
- {
- General = 1,
- Internal = 2,
- Customer = 3,
- Vendor = 4,
- System = 5
- }
-
- public enum RecordType
- {
- WorkOrder = 1,
- Dispatch = 2,
- Quote = 3,
- Contact = 4,
- Asset = 5
- }
-}
+namespace Data.SeaHavenIndustries.Enums
+{
+ /// Legacy enum — prefer LifecycleStatus for board operations.
+ public enum WorkOrderStatus
+ {
+ Open = 1,
+ InProgress = 2,
+ Completed = 3,
+ Cancelled = 4,
+ OnHold = 5
+ }
+
+ /// 10 operational lifecycle values aligned with SHOC board (FE labels).
+ public enum LifecycleStatus
+ {
+ Incomplete = 1,
+ Pending = 2,
+ Scheduled = 3,
+ EnRoute = 4,
+ OnSite = 5,
+ InProgress = 6,
+ Completed = 7,
+ Rescheduled = 8,
+ Canceled = 9,
+ PendingQuote = 10
+ }
+
+ /// Board work-order types. Legacy values retained for backward compatibility.
+ public enum WorkOrderType
+ {
+ PO = 1,
+ PM = 2,
+ Emergency = 3,
+ Project = 4,
+ Inspection = 5,
+ Reactive = 6,
+ AddOn = 7,
+ Other = 99
+ }
+
+ public enum DocStatus
+ {
+ Yes = 1,
+ No = 2,
+ NotNeeded = 3
+ }
+
+ [Flags]
+ public enum OperationalFlags
+ {
+ None = 0,
+ PastDue = 1
+ }
+
+ public enum AuditEventType
+ {
+ Manual = 1,
+ System = 2,
+ Sync = 3,
+ Vendor = 4
+ }
+
+ public enum AuditActorType
+ {
+ Dispatcher = 1,
+ Vendor = 2,
+ System = 3,
+ Sync = 4
+ }
+
+ public enum AuditActionType
+ {
+ FieldChanged = 1,
+ StatusChanged = 2,
+ AssignmentChanged = 3,
+ WeekRolled = 4,
+ SyncRejected = 5,
+ Create = 6,
+ Delete = 7
+ }
+
+ public enum WorkOrderPriority
+ {
+ Low = 1,
+ Medium = 2,
+ High = 3,
+ Critical = 4
+ }
+
+ public enum AuditAction
+ {
+ Create = 1,
+ Edit = 2,
+ Delete = 3,
+ StatusChange = 4,
+ AssignmentChange = 5,
+ Dispatch = 6,
+ DispatchUpdate = 7,
+ DispatchVerify = 8
+ }
+
+ public enum ContactType
+ {
+ Primary = 1,
+ Secondary = 2,
+ Billing = 3,
+ Technical = 4
+ }
+
+ public enum AssetStatus
+ {
+ Active = 1,
+ Inactive = 2,
+ UnderMaintenance = 3,
+ Retired = 4
+ }
+
+ public enum DispatchStatus
+ {
+ Pending = 1,
+ Assigned = 2,
+ InProgress = 3,
+ Completed = 4,
+ Cancelled = 5
+ }
+
+ public enum FollowUpStatus
+ {
+ Open = 1,
+ InProgress = 2,
+ Resolved = 3,
+ Closed = 4
+ }
+
+ public enum PMScheduleFrequency
+ {
+ Daily = 1,
+ Weekly = 2,
+ Monthly = 3,
+ Quarterly = 4,
+ SemiAnnual = 5,
+ Annual = 6
+ }
+
+ public enum CommentType
+ {
+ General = 1,
+ Internal = 2,
+ Customer = 3,
+ Vendor = 4,
+ System = 5
+ }
+
+ public enum RecordType
+ {
+ WorkOrder = 1,
+ Dispatch = 2,
+ Quote = 3,
+ Contact = 4,
+ Asset = 5
+ }
+
+ public enum WorkOrderMediaCategory
+ {
+ Before = 1,
+ After = 2,
+ Extra = 3,
+ Completion = 4
+ }
+}
+
\ No newline at end of file
diff --git a/Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.Designer.cs
index 5d7ba26..5092591 100644
--- a/Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.Designer.cs
+++ b/Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.Designer.cs
@@ -1975,6 +1975,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property("Severity")
.HasColumnType("nvarchar(max)");
+ b.Property("Service")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
b.Property("SignOffAttachment")
.HasColumnType("nvarchar(max)");
diff --git a/Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.cs b/Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.cs
index 755fef1..13f64da 100644
--- a/Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.cs
+++ b/Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.cs
@@ -264,6 +264,13 @@ namespace Data.SeaHavenIndustries.Migrations
type: "int",
nullable: true);
+ migrationBuilder.AddColumn(
+ name: "Service",
+ table: "workOrders",
+ type: "nvarchar(128)",
+ maxLength: 128,
+ nullable: true);
+
migrationBuilder.AddColumn(
name: "Notes",
table: "WorkOrderContacts",
@@ -367,17 +374,17 @@ namespace Data.SeaHavenIndustries.Migrations
migrationBuilder.Sql("""
UPDATE workOrders SET LifecycleStatus = CASE
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('open') THEN 1
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('scheduled') THEN 2
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('inprogress', 'in progress') THEN 3
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('completed', 'complete') THEN 4
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('cancelled', 'canceled') THEN 5
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('onhold', 'on hold') THEN 6
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pendingvendor', 'pending vendor') THEN 7
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pendingapproval', 'pending approval') THEN 8
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('needsreview', 'needs review') THEN 9
- WHEN LOWER(LTRIM(RTRIM(Status))) IN ('closed') THEN 10
- ELSE 9
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('open', 'incomplete') THEN 1
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending', 'onhold', 'on hold') THEN 2
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('scheduled') THEN 3
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('en route', 'enroute') THEN 4
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('on site', 'onsite') THEN 5
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('inprogress', 'in progress') THEN 6
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('completed', 'complete', 'done', 'closed') THEN 7
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('rescheduled') THEN 8
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('cancelled', 'canceled') THEN 9
+ WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending quote', 'pendingquote', 'pendingapproval', 'pending approval') THEN 10
+ ELSE 1
END
WHERE LifecycleStatus IS NULL AND Status IS NOT NULL;
""");
@@ -477,6 +484,10 @@ namespace Data.SeaHavenIndustries.Migrations
name: "WorkOrderType",
table: "workOrders");
+ migrationBuilder.DropColumn(
+ name: "Service",
+ table: "workOrders");
+
migrationBuilder.DropColumn(
name: "Notes",
table: "WorkOrderContacts");
diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs
new file mode 100644
index 0000000..23968a8
--- /dev/null
+++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs
@@ -0,0 +1,64 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Data.SeaHavenIndustries.Migrations
+{
+ ///
+ public class Phase1_BoardIndexes : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = 'IX_workOrders_ScheduledDate'
+ AND object_id = OBJECT_ID('workOrders'))
+ BEGIN
+ CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate);
+ END
+
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
+ AND object_id = OBJECT_ID('workOrders'))
+ BEGIN
+ CREATE INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders (AssignTo, ScheduledDate);
+ END
+
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = 'IX_workOrders_WorkOrderType'
+ AND object_id = OBJECT_ID('workOrders'))
+ BEGIN
+ CREATE INDEX IX_workOrders_WorkOrderType ON workOrders (WorkOrderType);
+ END
+ """);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ IF EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = 'IX_workOrders_WorkOrderType'
+ AND object_id = OBJECT_ID('workOrders'))
+ DROP INDEX IX_workOrders_WorkOrderType ON workOrders;
+
+ IF EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
+ AND object_id = OBJECT_ID('workOrders'))
+ DROP INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders;
+
+ IF EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = 'IX_workOrders_ScheduledDate'
+ AND object_id = OBJECT_ID('workOrders'))
+ DROP INDEX IX_workOrders_ScheduledDate ON workOrders;
+ """);
+ }
+ }
+}
diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
index e2cbaec..061ccd8 100644
--- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -1972,6 +1972,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property("Severity")
.HasColumnType("nvarchar(max)");
+ b.Property("Service")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
b.Property("SignOffAttachment")
.HasColumnType("nvarchar(max)");
@@ -2027,10 +2031,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AssignTo");
+ b.HasIndex("AssignTo", "ScheduledDate");
+
b.HasIndex("LocationId");
b.HasIndex("PrimaryDispatchId");
+ b.HasIndex("ScheduledDate");
+
+ b.HasIndex("WorkOrderType");
+
b.ToTable("workOrders");
});
diff --git a/Data.SeaHavenIndustries/Models/Events.cs b/Data.SeaHavenIndustries/Models/Events.cs
new file mode 100644
index 0000000..97ed818
--- /dev/null
+++ b/Data.SeaHavenIndustries/Models/Events.cs
@@ -0,0 +1,49 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+
+namespace Data.SeaHavenIndustries
+{
+ ///
+ /// Events entity for Calendar/Events module
+ /// Tracks calendar events, appointments, meetings, etc.
+ ///
+ public class Events : FullAuditEntity
+ {
+ [Required]
+ [StringLength(100)]
+ public string Title { get; set; } = null!;
+
+ public string? Description { get; set; }
+
+ [StringLength(150)]
+ public string? Location { get; set; }
+
+ // Related Record (can link to Accounts, Contacts, Work Orders, etc.)
+ public int? RecordID { get; set; }
+ public string? RecordName { get; set; }
+ public string? RecordTargetType { get; set; }
+
+ // Reminder Settings
+ public int ReminderMinutes { get; set; } = 15; // Default: 15 minutes before
+
+ // Color for calendar display
+ [StringLength(20)]
+ public string EventColor { get; set; } = "primary"; // primary, success, info, warning, danger
+
+ // Schedule
+ [Required]
+ public DateTime StartDate { get; set; }
+
+ [StringLength(10)]
+ public string? StartTime { get; set; } // e.g., "08:00 AM"
+
+ [Required]
+ public DateTime EndDate { get; set; }
+
+ [StringLength(10)]
+ public string? EndTime { get; set; } // e.g., "08:30 AM"
+
+ public bool AllDay { get; set; } = false;
+ }
+}
+
diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
index 5bc5d4a..18e51bf 100644
--- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs
+++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
@@ -4,10 +4,6 @@ using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
- ///
- /// Work Order aggregate root. Domain slices (Core, Scheduling, Tracking, Analytics, Completion)
- /// are persisted as flat columns on workOrders for Fase 0 backward compatibility.
- ///
public class WorkOrder : FullAuditEntity
{
// --- Core slice ---
@@ -30,6 +26,8 @@ namespace Data.SeaHavenIndustries
public LifecycleStatus? LifecycleStatus { get; set; }
public string? LegacyStatus { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
+ /// SHOC board "Service" column (PM type label). Not Trade/Problem.
+ public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; }
diff --git a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs
new file mode 100644
index 0000000..bceff32
--- /dev/null
+++ b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs
@@ -0,0 +1,7 @@
+namespace SeaHaven.DataServices.Board
+{
+ public static class WorkOrderBoardConstants
+ {
+ public const int ClientSideThreshold = 300;
+ }
+}
diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs
new file mode 100644
index 0000000..6d5d1e0
--- /dev/null
+++ b/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs
@@ -0,0 +1,10 @@
+namespace SeaHaven.DataServices.DTOs
+{
+ public record WorkOrderBoardQuery(
+ DateOnly ScheduledFrom,
+ DateOnly ScheduledTo,
+ string? Search,
+ IReadOnlyList? AssigneeIds,
+ IReadOnlyList? DbStatuses,
+ int? LocationId);
+}
diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs
new file mode 100644
index 0000000..a7eca60
--- /dev/null
+++ b/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs
@@ -0,0 +1,20 @@
+namespace SeaHaven.DataServices.DTOs
+{
+ public record WorkOrderBoardRowData(
+ int Id,
+ string? WoNumber,
+ string? SiteCode,
+ string? LocationLabel,
+ DateTime? ScheduledDate,
+ DateTime? ScheduledStart,
+ DateTime? DueDate,
+ string? WorkOrderType,
+ string? ServiceType,
+ string? AssigneeId,
+ string? AssigneeName,
+ string? VendorCompany,
+ string? VendorTechnician,
+ string? Status,
+ string? PocName,
+ string? PocPhone);
+}
diff --git a/SeaHaven.DataServices/Exceptions/BoardQueryException.cs b/SeaHaven.DataServices/Exceptions/BoardQueryException.cs
new file mode 100644
index 0000000..b659424
--- /dev/null
+++ b/SeaHaven.DataServices/Exceptions/BoardQueryException.cs
@@ -0,0 +1,9 @@
+namespace SeaHaven.DataServices.Exceptions
+{
+ public class BoardQueryException : Exception
+ {
+ public BoardQueryException(string message) : base(message)
+ {
+ }
+ }
+}
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
index bb9919b..35465ca 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
@@ -134,5 +134,24 @@ namespace SeaHaven.DataServices.Helpers
&& w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo));
}
+
+ public static IQueryable ApplySearchFilter(IQueryable query, string? search)
+ {
+ if (string.IsNullOrWhiteSpace(search))
+ return query;
+
+ var s = search.Trim().ToLower();
+ return query.Where(w =>
+ (w.SiteCode ?? "").ToLower().Contains(s)
+ || (w.InternalWONumber ?? "").ToLower().Contains(s)
+ || (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
+ || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
+ || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
+ || (w.Service ?? "").ToLower().Contains(s)
+ || (w.Trade ?? "").ToLower().Contains(s)
+ || (w.Problem ?? "").ToLower().Contains(s)
+ || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
+ || (w.Status ?? "").ToLower().Contains(s));
+ }
}
}
diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs
index a25aa35..05a4e82 100644
--- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs
+++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs
@@ -20,12 +20,10 @@ namespace SeaHaven.DataServices.Implementation
var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue);
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
- var baseQuery = _context.workOrders
- .AsNoTracking()
- .Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
-
- baseQuery = ApplyDispatcherFilter(baseQuery, query);
- baseQuery = ApplyTypeFilter(baseQuery, query);
+ var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
+ baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
+ baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
+ baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
var scheduledBase = baseQuery.Where(w =>
(w.ScheduledDate != null
@@ -42,12 +40,16 @@ namespace SeaHaven.DataServices.Implementation
if (!string.IsNullOrWhiteSpace(query.Search))
{
- scheduledBase = ApplySearchFilter(scheduledBase, query.Search);
- unscheduledBase = ApplySearchFilter(unscheduledBase, query.Search);
+ scheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(scheduledBase, query.Search);
+ unscheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(unscheduledBase, query.Search);
}
- var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(scheduledBase, isUnscheduled: false);
- var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(unscheduledBase, isUnscheduled: true);
+ var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
+ scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber),
+ isUnscheduled: false);
+ var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
+ unscheduledBase.OrderBy(w => w.InternalWONumber),
+ isUnscheduled: true);
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
}
@@ -89,52 +91,5 @@ namespace SeaHaven.DataServices.Implementation
&& w.InternalWONumber == normalizedWoNumber);
}
- private static IQueryable ApplyDispatcherFilter(IQueryable query, WorkOrderBoardQuery boardQuery)
- {
- if (boardQuery.MyWorkOrders && !string.IsNullOrEmpty(boardQuery.CurrentUserId))
- return query.Where(w => w.AssignTo == boardQuery.CurrentUserId);
-
- if (boardQuery.Dispatchers == null || boardQuery.Dispatchers.Count == 0)
- return query;
-
- var ids = boardQuery.Dispatchers
- .Where(d => d != "__unassigned__")
- .ToList();
- var includeUnassigned = boardQuery.Dispatchers.Contains("__unassigned__");
-
- if (includeUnassigned && ids.Count == 0)
- return query.Where(w => w.AssignTo == null || w.AssignTo == "");
-
- if (includeUnassigned && ids.Count > 0)
- {
- return query.Where(w =>
- w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
- }
-
- return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
- }
-
- private static IQueryable ApplyTypeFilter(IQueryable query, WorkOrderBoardQuery boardQuery)
- {
- if (boardQuery.Types == null || boardQuery.Types.Count == 0)
- return query;
-
- return query.Where(w => w.WorkOrderType != null && boardQuery.Types.Contains(w.WorkOrderType.Value));
- }
-
- private static IQueryable ApplySearchFilter(IQueryable query, string search)
- {
- var s = search.Trim().ToLower();
- return query.Where(w =>
- (w.SiteCode ?? "").ToLower().Contains(s)
- || (w.InternalWONumber ?? "").ToLower().Contains(s)
- || (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
- || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
- || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
- || (w.Trade ?? "").ToLower().Contains(s)
- || (w.Problem ?? "").ToLower().Contains(s)
- || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
- || (w.Status ?? "").ToLower().Contains(s));
- }
}
}
diff --git a/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs b/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs
new file mode 100644
index 0000000..ce6ebf6
--- /dev/null
+++ b/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs
@@ -0,0 +1,9 @@
+namespace SeaHaven.DataServices.Interfaces
+{
+ public record PrimaryDispatchProjection(
+ int WorkOrderId,
+ int? DispatchId,
+ string? VendorName,
+ DateTime? ApptDate,
+ string? DispatchStatus);
+}
diff --git a/SeaHaven.Services.Tests/PagedResultTests.cs b/SeaHaven.Services.Tests/PagedResultTests.cs
new file mode 100644
index 0000000..911370e
--- /dev/null
+++ b/SeaHaven.Services.Tests/PagedResultTests.cs
@@ -0,0 +1,34 @@
+using FluentAssertions;
+using SeaHaven.Services.DTOs;
+using Xunit;
+
+namespace SeaHaven.Services.Tests;
+
+public class PagedResultTests
+{
+ [Theory]
+ [InlineData(0, 10, 0)]
+ [InlineData(10, 10, 1)]
+ [InlineData(25, 10, 3)]
+ [InlineData(100, 25, 4)]
+ public void TotalPages_IsCeilingOfCountOverPageSize(int totalCount, int pageSize, int expectedPages)
+ {
+ var result = new PagedResult { TotalCount = totalCount, PageSize = pageSize, Page = 1 };
+
+ result.TotalPages.Should().Be(expectedPages);
+ }
+
+ [Fact]
+ public void HasPrevious_IsFalseOnFirstPage_TrueAfterward()
+ {
+ new PagedResult { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse();
+ new PagedResult { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue();
+ }
+
+ [Fact]
+ public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage()
+ {
+ new PagedResult { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue();
+ new PagedResult { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse();
+ }
+}
diff --git a/SeaHaven.Services.Tests/SeaHaven.Services.Tests.csproj b/SeaHaven.Services.Tests/SeaHaven.Services.Tests.csproj
new file mode 100644
index 0000000..fdbbfc2
--- /dev/null
+++ b/SeaHaven.Services.Tests/SeaHaven.Services.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SeaHaven.Services.Tests/ValidationTests.cs b/SeaHaven.Services.Tests/ValidationTests.cs
new file mode 100644
index 0000000..5d682a9
--- /dev/null
+++ b/SeaHaven.Services.Tests/ValidationTests.cs
@@ -0,0 +1,104 @@
+using FluentAssertions;
+using SeaHaven.Services.DTOs;
+using SeaHaven.Services.Validation;
+using Xunit;
+
+namespace SeaHaven.Services.Tests;
+
+public class CreateAssetValidationTests
+{
+ private readonly CreateAssetValidation _validator = new();
+
+ [Fact]
+ public void Validate_WhenNameEmpty_Fails()
+ {
+ var result = _validator.Validate(new CreateAssetDTO { Name = "" });
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateAssetDTO.Name));
+ }
+
+ [Fact]
+ public void Validate_WhenNameTooLong_Fails()
+ {
+ var result = _validator.Validate(new CreateAssetDTO { Name = new string('a', 201) });
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateAssetDTO.Name));
+ }
+
+ [Fact]
+ public void Validate_WhenLocationIdZero_Fails()
+ {
+ var result = _validator.Validate(new CreateAssetDTO { Name = "Pump", LocationId = 0 });
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateAssetDTO.LocationId));
+ }
+
+ [Fact]
+ public void Validate_WhenValid_Passes()
+ {
+ var result = _validator.Validate(new CreateAssetDTO { Name = "Pump", LocationId = 5 });
+
+ result.IsValid.Should().BeTrue();
+ }
+}
+
+public class CreateContactValidationTests
+{
+ private readonly CreateContactValidation _validator = new();
+
+ private static CreateContactDTO Valid() => new()
+ {
+ FirstName = "Ada",
+ LastName = "Lovelace",
+ Email = "ada@example.com"
+ };
+
+ [Fact]
+ public void Validate_WhenNamesMissing_Fails()
+ {
+ var dto = Valid();
+ dto.FirstName = "";
+ dto.LastName = "";
+
+ var result = _validator.Validate(dto);
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateContactDTO.FirstName));
+ result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateContactDTO.LastName));
+ }
+
+ [Fact]
+ public void Validate_WhenEmailMalformed_Fails()
+ {
+ var dto = Valid();
+ dto.Email = "not-an-email";
+
+ var result = _validator.Validate(dto);
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateContactDTO.Email));
+ }
+
+ [Fact]
+ public void Validate_WhenPhoneTooLong_Fails()
+ {
+ var dto = Valid();
+ dto.Phone = new string('1', 21);
+
+ var result = _validator.Validate(dto);
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateContactDTO.Phone));
+ }
+
+ [Fact]
+ public void Validate_WhenValid_Passes()
+ {
+ var result = _validator.Validate(Valid());
+
+ result.IsValid.Should().BeTrue();
+ }
+}
diff --git a/SeaHaven.Services.Tests/WorkOrderMappingTests.cs b/SeaHaven.Services.Tests/WorkOrderMappingTests.cs
new file mode 100644
index 0000000..28f6ea3
--- /dev/null
+++ b/SeaHaven.Services.Tests/WorkOrderMappingTests.cs
@@ -0,0 +1,74 @@
+using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using FluentAssertions;
+using SeaHaven.Services.DTOs;
+using Xunit;
+
+namespace SeaHaven.Services.Tests;
+
+public class WorkOrderMappingTests
+{
+ [Fact]
+ public void MapToModel_WithValidEnumStrings_ParsesStatusAndPriority()
+ {
+ var entity = new WorkOrder
+ {
+ Id = 7,
+ WorkerOrderTitle = "Fix pump",
+ Status = "InProgress",
+ Priority = "High"
+ };
+
+ var dto = WorkOrderDTO.MapToModel(entity);
+
+ dto.Id.Should().Be(7);
+ dto.WorkerOrderTitle.Should().Be("Fix pump");
+ dto.Status.Should().Be(WorkOrderStatus.InProgress);
+ dto.Priority.Should().Be(WorkOrderPriority.High);
+ }
+
+ [Fact]
+ public void MapToModel_WithUnparseableEnumStrings_FallsBackToDefaults()
+ {
+ var entity = new WorkOrder
+ {
+ WorkerOrderTitle = "Garbage",
+ Status = "not-a-status",
+ Priority = "not-a-priority"
+ };
+
+ var dto = WorkOrderDTO.MapToModel(entity);
+
+ dto.Status.Should().Be(WorkOrderStatus.Open);
+ dto.Priority.Should().Be(WorkOrderPriority.Medium);
+ }
+
+ [Fact]
+ public void MapToEntity_SerializesEnumsToStrings()
+ {
+ var dto = new WorkOrderDTO
+ {
+ Id = 3,
+ WorkerOrderTitle = "Inspect",
+ Status = WorkOrderStatus.Completed,
+ Priority = WorkOrderPriority.Critical
+ };
+
+ var entity = WorkOrderDTO.MapToEntity(dto);
+
+ entity.Status.Should().Be("Completed");
+ entity.Priority.Should().Be("Critical");
+ }
+
+ [Fact]
+ public void CreateMapToEntity_WhenStatusAndPriorityNull_AppliesDefaults()
+ {
+ var dto = new CreateWorkOrderDTO { WorkerOrderTitle = "New" };
+
+ var entity = CreateWorkOrderDTO.MapToEntity(dto);
+
+ entity.Status.Should().Be("Open");
+ entity.Priority.Should().Be("Medium");
+ entity.CreatedDate.Should().NotBeNull();
+ }
+}
diff --git a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs
new file mode 100644
index 0000000..a00f3e2
--- /dev/null
+++ b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs
@@ -0,0 +1,8 @@
+namespace SeaHaven.Services.Board
+{
+ public static class WorkOrderBoardConstants
+ {
+ public const int MaxWindowDays = 90;
+ public const int ClientSideThreshold = 300;
+ }
+}
diff --git a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs
new file mode 100644
index 0000000..967e102
--- /dev/null
+++ b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs
@@ -0,0 +1,26 @@
+namespace SeaHaven.Services.Constants
+{
+ public static class WorkOrderFieldNames
+ {
+ public const string LifecycleStatus = nameof(LifecycleStatus);
+ public const string AssignTo = nameof(AssignTo);
+ public const string ScheduledDate = nameof(ScheduledDate);
+ public const string ScheduledEnd = nameof(ScheduledEnd);
+ public const string TargetWeek = nameof(TargetWeek);
+ public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);
+ public const string DueDate = nameof(DueDate);
+ public const string Description = nameof(Description);
+ public const string WorkerOrderTitle = nameof(WorkerOrderTitle);
+ public const string SiteCode = nameof(SiteCode);
+ public const string Building = nameof(Building);
+ public const string LocationId = nameof(LocationId);
+ public const string WorkOrderType = nameof(WorkOrderType);
+ public const string Service = nameof(Service);
+ public const string DocStatus = nameof(DocStatus);
+ public const string Trade = nameof(Trade);
+ public const string Problem = nameof(Problem);
+ public const string Priority = nameof(Priority);
+ public const string InternalWONumber = nameof(InternalWONumber);
+ public const string Status = nameof(Status);
+ }
+}
diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
index 8944001..f50949a 100644
--- a/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
@@ -12,7 +12,7 @@ namespace SeaHaven.Services.Helpers
return true;
var trimmed = value.Trim();
- var parts = trimmed.Split(new[] { '–', '—', '-' }, StringSplitOptions.RemoveEmptyEntries)
+ var parts = trimmed.Split(new[] { '–', '—', '-' })
.Select(p => p.Trim())
.Where(p => !string.IsNullOrEmpty(p))
.ToArray();
diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs
index b349a0e..cb64549 100644
--- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs
@@ -1,9 +1,25 @@
+using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderDerivedFields
{
+ ///
+ /// Past due overlay — never a WorkOrderType. Matches SHOC board design.
+ ///
+ public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
+ {
+ var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
+ if (workOrder.ScheduledDate == null)
+ return false;
+
+ if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
+ return false;
+
+ return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
+ }
+
public static bool IsPastDue(
DateTime? scheduledDate,
LifecycleStatus? lifecycleStatus,
@@ -19,8 +35,36 @@ namespace SeaHaven.Services.Helpers
return scheduledDate.Value.Date < today;
}
- public static bool IsTerminal(LifecycleStatus? lifecycleStatus)
- => LifecycleStatusMapper.IsTerminal(lifecycleStatus);
+ ///
+ /// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
+ ///
+ public static void SetInitialLifecycleStatus(WorkOrder workOrder)
+ {
+ workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
+ ApplyAutoScheduleIfEligible(workOrder);
+ }
+
+ ///
+ /// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
+ /// Week-only targets do not auto-schedule.
+ ///
+ public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
+ {
+ if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
+ return false;
+
+ if (workOrder.ScheduleWeekOnly == true)
+ return false;
+
+ if (workOrder.ScheduledDate == null)
+ return false;
+
+ if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
+ return false;
+
+ workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
+ return true;
+ }
public static string? GetDayGroup(DateTime? scheduledDate)
{
diff --git a/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
new file mode 100644
index 0000000..5779a17
--- /dev/null
+++ b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
@@ -0,0 +1,20 @@
+namespace SeaHaven.Services.Helpers
+{
+ public static class WorkOrderOperationalWeek
+ {
+ public static DateOnly GetMondayOfWeek(DateOnly date)
+ {
+ var daysFromMonday = ((int)date.DayOfWeek + 6) % 7;
+ return date.AddDays(-daysFromMonday);
+ }
+
+ public static DateOnly GetPreviousOperationalWeekStart(DateTime utcNow)
+ => GetMondayOfWeek(DateOnly.FromDateTime(utcNow)).AddDays(-7);
+
+ public static DateOnly GetOperationalWeekEnd(DateOnly weekStartMonday)
+ => weekStartMonday.AddDays(4);
+
+ public static string BuildWeekCorrelationId(DateOnly sourceWeekStart)
+ => $"week:{sourceWeekStart:yyyy-MM-dd}";
+ }
+}
diff --git a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs
index 6d564e3..eefdcbd 100644
--- a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs
+++ b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs
@@ -1,4 +1,6 @@
using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@@ -7,24 +9,39 @@ namespace SeaHaven.Services.Implementation
{
private static readonly HashSet ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
{
- "LifecycleStatus", "AssignTo", "ScheduledDate", "ScheduledEnd", "TargetWeek", "ScheduleWeekOnly",
- "DueDate", "Description", "WorkerOrderTitle", "SiteCode", "Building", "LocationId",
- "WorkOrderType", "DocStatus", "Trade", "Problem", "Priority", "InternalWONumber", "Status"
+ WorkOrderFieldNames.LifecycleStatus,
+ WorkOrderFieldNames.AssignTo,
+ WorkOrderFieldNames.ScheduledDate,
+ WorkOrderFieldNames.ScheduledEnd,
+ WorkOrderFieldNames.TargetWeek,
+ WorkOrderFieldNames.ScheduleWeekOnly,
+ WorkOrderFieldNames.DueDate,
+ WorkOrderFieldNames.Description,
+ WorkOrderFieldNames.WorkerOrderTitle,
+ WorkOrderFieldNames.SiteCode,
+ WorkOrderFieldNames.Building,
+ WorkOrderFieldNames.LocationId,
+ WorkOrderFieldNames.WorkOrderType,
+ WorkOrderFieldNames.Service,
+ WorkOrderFieldNames.DocStatus,
+ WorkOrderFieldNames.Trade,
+ WorkOrderFieldNames.Problem,
+ WorkOrderFieldNames.Priority,
+ WorkOrderFieldNames.InternalWONumber,
+ WorkOrderFieldNames.Status,
};
private readonly IWorkOrderFieldLockService _fieldLocks;
- private readonly IWorkOrderAuditService _audit;
- public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks, IWorkOrderAuditService audit)
+ public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks)
{
_fieldLocks = fieldLocks;
- _audit = audit;
}
public IReadOnlySet ShocOwnedFields => ShocOwnedFieldSet;
- public bool CanSyncOverwrite(int workOrderId, string fieldName)
- => !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult();
+ public async Task CanSyncOverwriteAsync(int workOrderId, string fieldName)
+ => !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
public async Task TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
{
@@ -41,52 +58,162 @@ namespace SeaHaven.Services.Implementation
return false;
}
- ApplyField(context.WorkOrder, fieldName, incomingValue);
- return true;
+ return TryApplyField(context.WorkOrder, fieldName, incomingValue);
}
private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch
{
- "Description" => wo.Description,
- "WorkerOrderTitle" => wo.WorkerOrderTitle,
- "Status" => wo.Status,
- "DueDate" => wo.DueDate?.ToString("O"),
- "SiteCode" => wo.SiteCode,
- "Building" => wo.Building,
- "Priority" => wo.Priority,
- "Severity" => wo.Severity,
+ WorkOrderFieldNames.Description => wo.Description,
+ WorkOrderFieldNames.WorkerOrderTitle => wo.WorkerOrderTitle,
+ WorkOrderFieldNames.Status => wo.Status,
+ WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
+ WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
+ WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
+ WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
+ WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
+ WorkOrderFieldNames.SiteCode => wo.SiteCode,
+ WorkOrderFieldNames.Building => wo.Building,
+ WorkOrderFieldNames.Priority => wo.Priority,
+ WorkOrderFieldNames.AssignTo => wo.AssignTo,
+ WorkOrderFieldNames.LocationId => wo.LocationId?.ToString(),
+ WorkOrderFieldNames.LifecycleStatus => wo.LifecycleStatus?.ToString(),
+ WorkOrderFieldNames.WorkOrderType => wo.WorkOrderType?.ToString(),
+ WorkOrderFieldNames.Service => wo.Service,
+ WorkOrderFieldNames.DocStatus => wo.DocStatus?.ToString(),
+ WorkOrderFieldNames.Trade => wo.Trade,
+ WorkOrderFieldNames.Problem => wo.Problem,
+ WorkOrderFieldNames.InternalWONumber => wo.InternalWONumber,
_ => null
};
- private static void ApplyField(WorkOrder wo, string fieldName, object incomingValue)
+ private static bool TryApplyField(WorkOrder wo, string fieldName, object incomingValue)
{
switch (fieldName)
{
- case "Description":
+ case WorkOrderFieldNames.Description:
wo.Description = incomingValue.ToString();
- break;
- case "WorkerOrderTitle":
+ return true;
+ case WorkOrderFieldNames.WorkerOrderTitle:
wo.WorkerOrderTitle = incomingValue.ToString();
- break;
- case "Status":
+ return true;
+ case WorkOrderFieldNames.Status:
wo.Status = incomingValue.ToString();
- break;
- case "DueDate" when incomingValue is DateTime dt:
- wo.DueDate = dt;
- break;
- case "SiteCode":
+ return true;
+ case WorkOrderFieldNames.DueDate when TryParseDateTime(incomingValue, out var dueDate):
+ wo.DueDate = dueDate;
+ return true;
+ case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
+ wo.ScheduledDate = scheduledDate;
+ return true;
+ case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
+ wo.ScheduledEnd = scheduledEnd;
+ return true;
+ case WorkOrderFieldNames.TargetWeek when TryParseDateOnly(incomingValue, out var targetWeek):
+ wo.TargetWeek = targetWeek;
+ return true;
+ case WorkOrderFieldNames.ScheduleWeekOnly when TryParseBool(incomingValue, out var scheduleWeekOnly):
+ wo.ScheduleWeekOnly = scheduleWeekOnly;
+ return true;
+ case WorkOrderFieldNames.SiteCode:
wo.SiteCode = incomingValue.ToString();
- break;
- case "Building":
+ return true;
+ case WorkOrderFieldNames.Building:
wo.Building = incomingValue.ToString();
- break;
- case "Priority":
+ return true;
+ case WorkOrderFieldNames.Priority:
wo.Priority = incomingValue.ToString();
- break;
- case "Severity":
- wo.Severity = incomingValue.ToString();
- break;
+ return true;
+ case WorkOrderFieldNames.AssignTo:
+ wo.AssignTo = incomingValue.ToString();
+ return true;
+ case WorkOrderFieldNames.LocationId when TryParseInt(incomingValue, out var locationId):
+ wo.LocationId = locationId;
+ return true;
+ case WorkOrderFieldNames.LifecycleStatus when TryParseEnum(incomingValue, out LifecycleStatus lifecycleStatus):
+ wo.LifecycleStatus = lifecycleStatus;
+ return true;
+ case WorkOrderFieldNames.WorkOrderType when TryParseEnum(incomingValue, out WorkOrderType workOrderType):
+ wo.WorkOrderType = workOrderType;
+ return true;
+ case WorkOrderFieldNames.Service:
+ wo.Service = incomingValue.ToString();
+ return true;
+ case WorkOrderFieldNames.DocStatus when TryParseEnum(incomingValue, out DocStatus docStatus):
+ wo.DocStatus = docStatus;
+ return true;
+ case WorkOrderFieldNames.Trade:
+ wo.Trade = incomingValue.ToString();
+ return true;
+ case WorkOrderFieldNames.Problem:
+ wo.Problem = incomingValue.ToString();
+ return true;
+ case WorkOrderFieldNames.InternalWONumber:
+ wo.InternalWONumber = incomingValue.ToString();
+ return true;
+ default:
+ return false;
}
}
+
+ private static bool TryParseDateTime(object value, out DateTime result)
+ {
+ if (value is DateTime dt)
+ {
+ result = dt;
+ return true;
+ }
+
+ return DateTime.TryParse(value.ToString(), out result);
+ }
+
+ private static bool TryParseDateOnly(object value, out DateOnly result)
+ {
+ if (value is DateOnly dateOnly)
+ {
+ result = dateOnly;
+ return true;
+ }
+
+ if (value is DateTime dt)
+ {
+ result = DateOnly.FromDateTime(dt);
+ return true;
+ }
+
+ return DateOnly.TryParse(value.ToString(), out result);
+ }
+
+ private static bool TryParseBool(object value, out bool result)
+ {
+ if (value is bool b)
+ {
+ result = b;
+ return true;
+ }
+
+ return bool.TryParse(value.ToString(), out result);
+ }
+
+ private static bool TryParseInt(object value, out int result)
+ {
+ if (value is int i)
+ {
+ result = i;
+ return true;
+ }
+
+ return int.TryParse(value.ToString(), out result);
+ }
+
+ private static bool TryParseEnum(object value, out TEnum result) where TEnum : struct, Enum
+ {
+ if (value is TEnum enumValue)
+ {
+ result = enumValue;
+ return true;
+ }
+
+ return Enum.TryParse(value.ToString(), true, out result);
+ }
}
}
diff --git a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs
index d132ea8..442fbb3 100644
--- a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs
@@ -15,14 +15,20 @@ namespace SeaHaven.Services.Implementation
public async Task IsLockedAsync(int workOrderId, string fieldName)
{
+ if (IsTracked(workOrderId, fieldName))
+ return true;
+
return await _context.WorkOrderFieldLocks
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
}
public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId)
{
- var exists = await IsLockedAsync(workOrderId, fieldName);
- if (exists) return;
+ if (IsTracked(workOrderId, fieldName))
+ return;
+
+ if (await IsLockedAsync(workOrderId, fieldName))
+ return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
@@ -31,7 +37,11 @@ namespace SeaHaven.Services.Implementation
LockedAt = DateTime.UtcNow,
LockedByUserId = userId
});
- await _context.SaveChangesAsync();
}
+
+ private bool IsTracked(int workOrderId, string fieldName)
+ => _context.WorkOrderFieldLocks.Local.Any(l =>
+ l.WorkOrderId == workOrderId &&
+ string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase));
}
}
diff --git a/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs b/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs
index 96b401d..12f9398 100644
--- a/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs
+++ b/SeaHaven.Services/Interfaces/ISyncFieldMergePolicy.cs
@@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
{
IReadOnlySet ShocOwnedFields { get; }
- bool CanSyncOverwrite(int workOrderId, string fieldName);
+ Task CanSyncOverwriteAsync(int workOrderId, string fieldName);
Task TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue);
}
diff --git a/SeaHavenIndustries.Tests/ConcurrencyExceptionFilterTests.cs b/SeaHavenIndustries.Tests/ConcurrencyExceptionFilterTests.cs
new file mode 100644
index 0000000..6ccfaf1
--- /dev/null
+++ b/SeaHavenIndustries.Tests/ConcurrencyExceptionFilterTests.cs
@@ -0,0 +1,50 @@
+using Api.SeaHavenIndustries.Filters;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+
+namespace SeaHavenIndustries.Tests;
+
+public class ConcurrencyExceptionFilterTests
+{
+ [Fact]
+ public void OnException_DbUpdateConcurrencyException_Returns409Conflict()
+ {
+ var filter = new ConcurrencyExceptionFilter();
+ var context = CreateExceptionContext(new DbUpdateConcurrencyException());
+
+ filter.OnException(context);
+
+ Assert.True(context.ExceptionHandled);
+ var result = Assert.IsType(context.Result);
+ Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode);
+ }
+
+ [Fact]
+ public void OnException_OtherException_DoesNotHandle()
+ {
+ var filter = new ConcurrencyExceptionFilter();
+ var context = CreateExceptionContext(new InvalidOperationException("other"));
+
+ filter.OnException(context);
+
+ Assert.False(context.ExceptionHandled);
+ Assert.Null(context.Result);
+ }
+
+ private static ExceptionContext CreateExceptionContext(Exception exception)
+ {
+ var actionContext = new ActionContext(
+ new DefaultHttpContext(),
+ new RouteData(),
+ new ActionDescriptor());
+
+ return new ExceptionContext(actionContext, [])
+ {
+ Exception = exception
+ };
+ }
+}
diff --git a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs
index bf99a85..0a06141 100644
--- a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs
+++ b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs
@@ -1,4 +1,5 @@
using Data.SeaHavenIndustries.Enums;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
@@ -7,43 +8,33 @@ public class LifecycleStatusMapperTests
{
[Theory]
[InlineData("Open", LifecycleStatus.Incomplete)]
+ [InlineData("Incomplete", LifecycleStatus.Incomplete)]
[InlineData("InProgress", LifecycleStatus.InProgress)]
[InlineData("In Progress", LifecycleStatus.InProgress)]
[InlineData("Completed", LifecycleStatus.Completed)]
[InlineData("Cancelled", LifecycleStatus.Canceled)]
[InlineData("On Hold", LifecycleStatus.Pending)]
[InlineData("Scheduled", LifecycleStatus.Scheduled)]
+ [InlineData("En Route", LifecycleStatus.EnRoute)]
+ [InlineData("Pending Quote", LifecycleStatus.PendingQuote)]
public void FromLegacyStatus_MapsKnownValues(string input, LifecycleStatus expected)
{
var result = LifecycleStatusMapper.FromLegacyStatus(input);
Assert.Equal(expected, result);
}
- [Theory]
- [InlineData("Incomplete", LifecycleStatus.Incomplete)]
- [InlineData("En Route", LifecycleStatus.EnRoute)]
- [InlineData("On Site", LifecycleStatus.OnSite)]
- [InlineData("Rescheduled", LifecycleStatus.Rescheduled)]
- [InlineData("Pending Quote", LifecycleStatus.PendingQuote)]
- public void ParseLifecycleStatus_MapsFeLabels(string input, LifecycleStatus expected)
- {
- var result = LifecycleStatusMapper.ParseLifecycleStatus(input);
- Assert.Equal(expected, result);
- }
-
- [Theory]
- [InlineData(LifecycleStatus.EnRoute, "En Route")]
- [InlineData(LifecycleStatus.OnSite, "On Site")]
- [InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]
- public void ToFeLabel_ReturnsFrontendLabels(LifecycleStatus status, string expected)
- {
- Assert.Equal(expected, LifecycleStatusMapper.ToFeLabel(status));
- }
-
[Fact]
public void FromLegacyStatus_Unknown_ReturnsNull()
{
var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus");
Assert.Null(result);
}
+
+ [Theory]
+ [InlineData(LifecycleStatus.EnRoute, "En Route")]
+ [InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]
+ public void ToFeLabel_ReturnsBoardLabel(LifecycleStatus status, string expected)
+ {
+ Assert.Equal(expected, LifecycleStatusMapper.ToFeLabel(status));
+ }
}
diff --git a/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj b/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj
index 70c68e1..5eb8d5d 100644
--- a/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj
+++ b/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj
@@ -20,6 +20,7 @@
+
diff --git a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs
index e88f262..e9831d7 100644
--- a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs
+++ b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs
@@ -1,5 +1,7 @@
using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
@@ -24,14 +26,14 @@ public class SyncFieldMergePolicyTests
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 10,
- FieldName = "Description",
+ FieldName = WorkOrderFieldNames.Description,
LockedAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
- var policy = new SyncFieldMergePolicy(locks, audit);
+ var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
@@ -40,7 +42,7 @@ public class SyncFieldMergePolicyTests
Audit = audit
};
- var applied = await policy.TryApplyAsync(syncContext, "Description", "Sync value");
+ var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "Sync value");
Assert.False(applied);
Assert.Equal("SHOC edited", wo.Description);
@@ -61,7 +63,7 @@ public class SyncFieldMergePolicyTests
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
- var policy = new SyncFieldMergePolicy(locks, audit);
+ var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
@@ -70,9 +72,80 @@ public class SyncFieldMergePolicyTests
Audit = audit
};
- var applied = await policy.TryApplyAsync(syncContext, "Description", "From sync");
+ var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "From sync");
Assert.True(applied);
Assert.Equal("From sync", wo.Description);
}
+
+ [Fact]
+ public async Task TryApply_UnsupportedField_ReturnsFalse()
+ {
+ await using var context = CreateContext();
+ var wo = new WorkOrder { Id = 12 };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ var locks = new WorkOrderFieldLockService(context);
+ var audit = new WorkOrderAuditService(context, locks);
+ var policy = new SyncFieldMergePolicy(locks);
+
+ var syncContext = new WorkOrderSyncContext
+ {
+ WorkOrder = wo,
+ FieldLocks = locks,
+ Audit = audit
+ };
+
+ var applied = await policy.TryApplyAsync(syncContext, "NotARealField", "value");
+
+ Assert.False(applied);
+ }
+
+ [Theory]
+ [InlineData(WorkOrderFieldNames.LifecycleStatus, LifecycleStatus.Scheduled)]
+ [InlineData(WorkOrderFieldNames.Service, "PM")]
+ [InlineData(WorkOrderFieldNames.DueDate, "2026-07-01T10:00:00Z")]
+ public async Task TryApply_ShocOwnedFields_AppliesValue(string fieldName, object incomingValue)
+ {
+ await using var context = CreateContext();
+ var wo = new WorkOrder { Id = 13 };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ var locks = new WorkOrderFieldLockService(context);
+ var audit = new WorkOrderAuditService(context, locks);
+ var policy = new SyncFieldMergePolicy(locks);
+
+ var syncContext = new WorkOrderSyncContext
+ {
+ WorkOrder = wo,
+ FieldLocks = locks,
+ Audit = audit
+ };
+
+ var applied = await policy.TryApplyAsync(syncContext, fieldName, incomingValue);
+
+ Assert.True(applied);
+ }
+
+ [Fact]
+ public async Task CanSyncOverwriteAsync_WhenLocked_ReturnsFalse()
+ {
+ await using var context = CreateContext();
+ context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
+ {
+ WorkOrderId = 20,
+ FieldName = WorkOrderFieldNames.AssignTo,
+ LockedAt = DateTime.UtcNow
+ });
+ await context.SaveChangesAsync();
+
+ var locks = new WorkOrderFieldLockService(context);
+ var policy = new SyncFieldMergePolicy(locks);
+
+ var canOverwrite = await policy.CanSyncOverwriteAsync(20, WorkOrderFieldNames.AssignTo);
+
+ Assert.False(canOverwrite);
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs
index f3058c3..f7df5ed 100644
--- a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs
@@ -1,6 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
+using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
@@ -33,7 +34,7 @@ public class WorkOrderAuditServiceTests
Assert.Equal("StatusChanged", logs[0].Action);
Assert.Equal("Manual", logs[0].EventType);
Assert.Equal("Dispatcher", logs[0].ActorType);
- Assert.Equal("LifecycleStatus", logs[0].FieldName);
+ Assert.Equal(WorkOrderFieldNames.LifecycleStatus, logs[0].FieldName);
}
[Fact]
@@ -50,6 +51,6 @@ public class WorkOrderAuditServiceTests
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Single(fieldLocks);
- Assert.Equal("LifecycleStatus", fieldLocks[0].FieldName);
+ Assert.Equal(WorkOrderFieldNames.LifecycleStatus, fieldLocks[0].FieldName);
}
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
index 9319831..8b1b867 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
@@ -170,13 +170,12 @@ public class WorkOrderBoardServiceTests
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
- var utcPast = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc);
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
- ScheduledDate = utcPast.AddDays(-2),
+ ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
LifecycleStatus = LifecycleStatus.Scheduled
});
@@ -193,7 +192,7 @@ public class WorkOrderBoardServiceTests
Assert.Equal(1, response.Counts.Total);
Assert.Single(response.Scheduled);
Assert.True(response.Scheduled[0].IsPastDue);
- Assert.Equal("monday", response.Scheduled[0].DayGroup);
+ Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
}
[Fact]
diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs
index 2dc86da..0d1f61c 100644
--- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs
@@ -1,3 +1,4 @@
+using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
@@ -5,76 +6,70 @@ namespace SeaHavenIndustries.Tests;
public class WorkOrderDerivedFieldsTests
{
- private static readonly DateTime Today = new(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc);
-
[Fact]
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
{
- var result = WorkOrderDerivedFields.IsPastDue(
- Today.AddDays(-1),
- LifecycleStatus.Scheduled,
- Today);
+ var wo = new WorkOrder
+ {
+ ScheduledDate = DateTime.UtcNow.AddDays(-2),
+ LifecycleStatus = LifecycleStatus.Scheduled
+ };
- Assert.True(result);
+ Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
}
[Fact]
- public void IsPastDue_WhenScheduledToday_ReturnsFalse()
+ public void IsPastDue_WhenCompleted_ReturnsFalse()
{
- var result = WorkOrderDerivedFields.IsPastDue(
- Today,
- LifecycleStatus.Scheduled,
- Today);
+ var wo = new WorkOrder
+ {
+ ScheduledDate = DateTime.UtcNow.AddDays(-2),
+ LifecycleStatus = LifecycleStatus.Completed
+ };
- Assert.False(result);
+ Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
}
[Fact]
- public void IsPastDue_WhenComplete_ReturnsFalse()
+ public void ApplyAutoSchedule_WhenDateAndAssignee_PromotesToScheduled()
{
- var result = WorkOrderDerivedFields.IsPastDue(
- Today.AddDays(-3),
- LifecycleStatus.Completed,
- Today);
+ var wo = new WorkOrder
+ {
+ LifecycleStatus = LifecycleStatus.Incomplete,
+ ScheduledDate = new DateTime(2026, 6, 25),
+ AssignTo = "dispatcher-1"
+ };
- Assert.False(result);
+ Assert.True(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
+ Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
}
[Fact]
- public void IsPastDue_WhenNoScheduledDate_ReturnsFalse()
+ public void ApplyAutoSchedule_WeekOnly_DoesNotPromote()
{
- var result = WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Incomplete, Today);
- Assert.False(result);
- }
+ var wo = new WorkOrder
+ {
+ LifecycleStatus = LifecycleStatus.Incomplete,
+ ScheduledDate = new DateTime(2026, 6, 25),
+ AssignTo = "dispatcher-1",
+ ScheduleWeekOnly = true,
+ TargetWeek = new DateOnly(2026, 6, 22)
+ };
- [Theory]
- [InlineData(DayOfWeek.Monday, "monday")]
- [InlineData(DayOfWeek.Friday, "friday")]
- [InlineData(DayOfWeek.Saturday, null)]
- public void GetDayGroup_MapsWeekdays(DayOfWeek day, string? expected)
- {
- var date = new DateTime(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc);
- while (date.DayOfWeek != day)
- date = date.AddDays(1);
-
- Assert.Equal(expected, WorkOrderDerivedFields.GetDayGroup(date));
+ Assert.False(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
+ Assert.Equal(LifecycleStatus.Incomplete, wo.LifecycleStatus);
}
[Fact]
- public void FormatApptTime_WithStartAndEnd_ReturnsRange()
+ public void SetInitialLifecycleStatus_NewWo_StartsIncompleteThenAutoSchedules()
{
- var start = new DateTime(2026, 6, 24, 7, 0, 0);
- var end = new DateTime(2026, 6, 24, 9, 0, 0);
+ var wo = new WorkOrder
+ {
+ ScheduledDate = new DateTime(2026, 6, 25),
+ AssignTo = "dispatcher-1"
+ };
- var result = WorkOrderDerivedFields.FormatApptTime(start, end);
-
- Assert.Equal("07:00 – 09:00", result);
- }
-
- [Fact]
- public void FormatApptTime_WithStartOnly_ReturnsTime()
- {
- var start = new DateTime(2026, 6, 24, 7, 30, 0);
- Assert.Equal("07:30", WorkOrderDerivedFields.FormatApptTime(start, null));
+ WorkOrderDerivedFields.SetInitialLifecycleStatus(wo);
+ Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
}
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs
new file mode 100644
index 0000000..b598a4d
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs
@@ -0,0 +1,53 @@
+using Data.SeaHavenIndustries;
+using Microsoft.EntityFrameworkCore;
+using SeaHaven.Services.Constants;
+using SeaHaven.Services.Implementation;
+
+namespace SeaHavenIndustries.Tests;
+
+public class WorkOrderFieldLockServiceTests
+{
+ private static ApplicationDbContext CreateContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ return new ApplicationDbContext(options);
+ }
+
+ [Fact]
+ public async Task LockFieldAsync_DuplicateCalls_EnqueuesSingleLock()
+ {
+ await using var context = CreateContext();
+ var locks = new WorkOrderFieldLockService(context);
+
+ await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-1");
+ await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-2");
+ await context.SaveChangesAsync();
+
+ var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
+ Assert.Single(fieldLocks);
+ Assert.Equal(WorkOrderFieldNames.Description, fieldLocks[0].FieldName);
+ }
+
+ [Fact]
+ public async Task LockFieldAsync_WhenAlreadyPersisted_IsIdempotent()
+ {
+ await using var context = CreateContext();
+ context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
+ {
+ WorkOrderId = 2,
+ FieldName = WorkOrderFieldNames.AssignTo,
+ LockedAt = DateTime.UtcNow,
+ LockedByUserId = "user-1"
+ });
+ await context.SaveChangesAsync();
+
+ var locks = new WorkOrderFieldLockService(context);
+ await locks.LockFieldAsync(2, WorkOrderFieldNames.AssignTo, "user-2");
+ await context.SaveChangesAsync();
+
+ var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
+ Assert.Single(fieldLocks);
+ }
+}
diff --git a/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs b/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs
new file mode 100644
index 0000000..b25bcd5
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs
@@ -0,0 +1,32 @@
+using Data.SeaHavenIndustries.Enums;
+using SeaHaven.Services.Helpers;
+
+namespace SeaHavenIndustries.Tests;
+
+public class WorkOrderTypeMapperTests
+{
+ [Theory]
+ [InlineData("PM", WorkOrderType.PM)]
+ [InlineData("Reactive", WorkOrderType.Reactive)]
+ [InlineData("Emergency", WorkOrderType.Emergency)]
+ [InlineData("Add-On", WorkOrderType.AddOn)]
+ public void TryParseFeLabel_BoardTypes(string label, WorkOrderType expected)
+ {
+ Assert.True(WorkOrderTypeMapper.TryParseFeLabel(label, out var type));
+ Assert.Equal(expected, type);
+ }
+
+ [Fact]
+ public void TryParseFeLabel_Overdue_ReturnsFalse()
+ {
+ Assert.False(WorkOrderTypeMapper.TryParseFeLabel("Overdue", out _));
+ }
+
+ [Theory]
+ [InlineData(WorkOrderType.AddOn, "Add-On")]
+ [InlineData(WorkOrderType.Reactive, "Reactive")]
+ public void ToFeLabel_ReturnsBoardLabel(WorkOrderType type, string expected)
+ {
+ Assert.Equal(expected, WorkOrderTypeMapper.ToFeLabel(type));
+ }
+}
diff --git a/SeaHavenIndustries.sln b/SeaHavenIndustries.sln
index e97473d..c814615 100644
--- a/SeaHavenIndustries.sln
+++ b/SeaHavenIndustries.sln
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.Services", "SeaHav
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.DataServices", "SeaHaven.DataServices\SeaHaven.DataServices.csproj", "{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.SeaHavenIndustries.Tests", "Api.SeaHavenIndustries.Tests\Api.SeaHavenIndustries.Tests.csproj", "{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.Services.Tests", "SeaHaven.Services.Tests\SeaHaven.Services.Tests.csproj", "{97455198-C479-47F2-8BC8-601B335DFFAB}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHavenIndustries.Tests", "SeaHavenIndustries.Tests\SeaHavenIndustries.Tests.csproj", "{0093B3F5-0180-4C02-A088-6A564E06CEBF}"
EndProject
Global
@@ -41,6 +45,14 @@ Global
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Release|Any CPU.Build.0 = Release|Any CPU
+ {97455198-C479-47F2-8BC8-601B335DFFAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {97455198-C479-47F2-8BC8-601B335DFFAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {97455198-C479-47F2-8BC8-601B335DFFAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {97455198-C479-47F2-8BC8-601B335DFFAB}.Release|Any CPU.Build.0 = Release|Any CPU
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
diff --git a/SeaHavenIndustries/SeaHavenIndustries.csproj b/SeaHavenIndustries/SeaHavenIndustries.csproj
index 11de19e..f3b6061 100644
--- a/SeaHavenIndustries/SeaHavenIndustries.csproj
+++ b/SeaHavenIndustries/SeaHavenIndustries.csproj
@@ -13,8 +13,8 @@
-
-
+
+