merge: integrate Phase 1 board API into Phase 2 branch

Combine the reviewed Phase 1 read-only board endpoints with Phase 2 inline edit and optimistic concurrency, resolving shared foundation conflicts while preserving both feature sets.
This commit is contained in:
Arthur Bassi 2026-07-07 13:59:22 -03:00
commit 91122d753d
50 changed files with 1914 additions and 361 deletions

3
.github/CODEOWNERS vendored Normal file
View file

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

11
.github/workflows/ci.yml vendored Normal file
View file

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

11
.github/workflows/labeler.yml vendored Normal file
View file

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

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
</ItemGroup>
</Project>

View file

@ -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<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static UserManager<ApplicationUser> MockUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
}
private static 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<OkObjectResult>().Subject;
ok.Value.Should().BeOfType<DataResponse>().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<BadRequestObjectResult>();
}
[Fact]
public async Task EditEvent_WhenEventNotFound_ReturnsNotFound()
{
using var ctx = NewContext();
var result = await NewController(ctx).EditEvent(ValidDto(id: 999));
result.Should().BeOfType<NotFoundObjectResult>();
}
[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<OkObjectResult>();
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<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<DataResponse>().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<NotFoundObjectResult>();
}
[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<OkObjectResult>();
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<NotFoundObjectResult>();
}
[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<OkObjectResult>().Subject;
var data = ok.Value.Should().BeOfType<DataResponse>().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<DataResponse>().Data!;
var rows = ((IEnumerable)Prop(data, "rows")).Cast<Event_DTO>().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<OkObjectResult>().Subject;
var events = ok.Value.As<DataResponse>().Data as IEnumerable<Event_DTO>;
events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
}
}

View file

@ -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<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
public CalendarController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
{
_userManager = userManager;
_db = db;
}
// ===================================================================================
// CREATE EVENT
// ===================================================================================
[HttpPost]
[Route("AddEvent")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
}
}

View file

@ -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<IActionResult> GetBoard(
[FromQuery] DateOnly weekStart,
[FromQuery] DateOnly? weekEnd = null,
[FromQuery] List<string>? dispatchers = null,
[FromQuery] bool myWorkOrders = false,
[FromQuery] List<WorkOrderType>? types = null,
[FromQuery] string? search = null)
{
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<IActionResult> GetDispatcherLookups()
{
var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
return Ok(result);
}
[HttpPatch("{id:int}/board")]
public async Task<IActionResult> PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
{

View file

@ -0,0 +1,48 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>
/// DTO for POST/PUT requests - Create/Edit Event
/// Used to create or update event information
/// </summary>
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;
}
}

View file

@ -0,0 +1,33 @@
using System;
namespace Api.SeaHavenIndustries.DTOs
{
/// <summary>
/// DTO for GET responses - Event data
/// Used to return event information to the client
/// </summary>
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; }
}
}

View file

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

View file

@ -22,7 +22,10 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddControllers().AddJsonOptions(options =>
builder.Services.AddControllers(options =>
{
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
});

View file

@ -85,6 +85,7 @@ namespace Data.SeaHavenIndustries
public DbSet<FollowUps> FollowUps { get; set; }
public DbSet<PMSchedules> PMSchedules { get; set; }
public DbSet<ContactDetails> ContactDetails { get; set; }
public DbSet<Events> Events { get; set; }
// Employee related DbSets
public DbSet<EmployeeAddress> EmployeeAddresses { get; set; }

View file

@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
</ItemGroup>
</Project>

View file

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

View file

@ -1975,6 +1975,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
b.Property<string>("Service")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SignOffAttachment")
.HasColumnType("nvarchar(max)");

View file

@ -264,6 +264,13 @@ namespace Data.SeaHavenIndustries.Migrations
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Service",
table: "workOrders",
type: "nvarchar(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "WorkOrderContacts",
@ -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");

View file

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

View file

@ -1972,6 +1972,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
b.Property<string>("Service")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("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");
});

View file

@ -0,0 +1,49 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// Events entity for Calendar/Events module
/// Tracks calendar events, appointments, meetings, etc.
/// </summary>
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;
}
}

View file

@ -4,10 +4,6 @@ using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// Work Order aggregate root. Domain slices (Core, Scheduling, Tracking, Analytics, Completion)
/// are persisted as flat columns on workOrders for Fase 0 backward compatibility.
/// </summary>
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; }
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; }

View file

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

View file

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

View file

@ -0,0 +1,20 @@
namespace SeaHaven.DataServices.DTOs
{
public record WorkOrderBoardRowData(
int Id,
string? WoNumber,
string? SiteCode,
string? LocationLabel,
DateTime? ScheduledDate,
DateTime? ScheduledStart,
DateTime? DueDate,
string? WorkOrderType,
string? ServiceType,
string? AssigneeId,
string? AssigneeName,
string? VendorCompany,
string? VendorTechnician,
string? Status,
string? PocName,
string? PocPhone);
}

View file

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

View file

@ -134,5 +134,24 @@ namespace SeaHaven.DataServices.Helpers
&& w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo));
}
public static IQueryable<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> 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));
}
}
}

View file

@ -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<WorkOrder> ApplyDispatcherFilter(IQueryable<WorkOrder> 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<WorkOrder> ApplyTypeFilter(IQueryable<WorkOrder> 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<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> 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));
}
}
}

View file

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

View file

@ -0,0 +1,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<string> { TotalCount = totalCount, PageSize = pageSize, Page = 1 };
result.TotalPages.Should().Be(expectedPages);
}
[Fact]
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward()
{
new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse();
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue();
}
[Fact]
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage()
{
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue();
new PagedResult<string> { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse();
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
</ItemGroup>
</Project>

View file

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

View file

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

View file

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

View file

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

View file

@ -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();

View file

@ -1,9 +1,25 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderDerivedFields
{
/// <summary>
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
/// </summary>
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
if (workOrder.ScheduledDate == null)
return false;
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
return false;
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
}
public static bool IsPastDue(
DateTime? scheduledDate,
LifecycleStatus? lifecycleStatus,
@ -19,8 +35,36 @@ namespace SeaHaven.Services.Helpers
return scheduledDate.Value.Date < today;
}
public static bool IsTerminal(LifecycleStatus? lifecycleStatus)
=> LifecycleStatusMapper.IsTerminal(lifecycleStatus);
/// <summary>
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
/// </summary>
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
{
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
ApplyAutoScheduleIfEligible(workOrder);
}
/// <summary>
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
/// Week-only targets do not auto-schedule.
/// </summary>
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
{
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
return false;
if (workOrder.ScheduleWeekOnly == true)
return false;
if (workOrder.ScheduledDate == null)
return false;
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
return false;
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
return true;
}
public static string? GetDayGroup(DateTime? scheduledDate)
{

View file

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

View file

@ -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<string> 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<string> ShocOwnedFields => ShocOwnedFieldSet;
public bool CanSyncOverwrite(int workOrderId, string fieldName)
=> !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult();
public async Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName)
=> !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
public async Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
{
@ -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<TEnum>(object value, out TEnum result) where TEnum : struct, Enum
{
if (value is TEnum enumValue)
{
result = enumValue;
return true;
}
return Enum.TryParse(value.ToString(), true, out result);
}
}
}

View file

@ -15,14 +15,20 @@ namespace SeaHaven.Services.Implementation
public async Task<bool> 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));
}
}

View file

@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
{
IReadOnlySet<string> ShocOwnedFields { get; }
bool CanSyncOverwrite(int workOrderId, string fieldName);
Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName);
Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue);
}

View file

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

View file

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

View file

@ -20,6 +20,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
</ItemGroup>

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ApplicationDbContext>()
.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);
}
}

View file

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

View file

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

View file

@ -13,8 +13,8 @@
<PackageReference Include="BlazorGoogleMaps" Version="4.7.7" />
<PackageReference Include="EPPlus" Version="7.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.8" />
<PackageReference Include="PasswordGenerator" Version="2.1.0" />
<PackageReference Include="PuppeteerSharp" Version="19.0.1" />
<PackageReference Include="Radzen.Blazor" Version="5.1.3" />