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