From f53a276f1dcab26c8d0d7abae54892696c885c19 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:14:27 -0400 Subject: [PATCH 01/16] Repo hygiene: PR labeler + README badges (INFRA-56/57) (#5) Add the callable PR labeler workflow. README badges skipped: no root README.md exists in this repo (only BACKEND_ARCHITECTURE.md). Dependabot unchanged. Part of INFRA-47 (INFRA-56, INFRA-57). --- .github/workflows/labeler.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/workflows/labeler.yml 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 From 02ff65b647893d79638ee5dd7f94a1e9ff5ce65b Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:47:27 -0400 Subject: [PATCH 02/16] Add CODEOWNERS requiring internal-dev review (#6) Assign all files to the internal-dev team so every pull request needs an approving review from an internal-dev member, giving internal engineering oversight of changes. The org main-branch-protection ruleset enforces this via required code-owner review; the internal-dev team has write access, so it is an eligible code owner. --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/CODEOWNERS 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 From d4d94a2e894959103d4f8ebdf51dde5db160c94e Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 22 Jun 2026 18:23:54 -0400 Subject: [PATCH 03/16] Add CI workflow calling org ci-dotnet reusable --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From dd1ffba06d40b7c4ac09526b3b25fd44dedacb4c Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 22 Jun 2026 18:23:54 -0400 Subject: [PATCH 04/16] Add CODEOWNERS requiring internal-dev review --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/CODEOWNERS 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 From 1f3972ae49425cd2b2c754834744644dbd64c9be Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:46:46 -0400 Subject: [PATCH 05/16] Add calendar/events backend API (#8) * Align EntityFrameworkCore.SqlServer and Tools to 8.0.8 * Add calendar/events backend API Cherry-picked from main-backup (19994ef); scratch notes file removed. * Require authentication on CalendarController Security review found [Authorize] commented out, leaving all 6 calendar endpoints anonymous. Enforce auth to match the API convention (17/23 controllers). * Add CalendarController unit tests (xUnit + EF InMemory) --- .../Api.SeaHavenIndustries.Tests.csproj | 25 ++ .../CalendarControllerTests.cs | 206 ++++++++++++ .../Controllers/CalendarController.cs | 306 ++++++++++++++++++ Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs | 48 +++ Api.SeaHavenIndustries/DTOs/Event_DTO.cs | 33 ++ .../Auth/ApplicationDbContext.cs | 1 + .../Data.SeaHavenIndustries.csproj | 2 +- Data.SeaHavenIndustries/Models/Events.cs | 49 +++ SeaHavenIndustries.sln | 6 + SeaHavenIndustries/SeaHavenIndustries.csproj | 4 +- 10 files changed, 677 insertions(+), 3 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj create mode 100644 Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/CalendarController.cs create mode 100644 Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs create mode 100644 Api.SeaHavenIndustries/DTOs/Event_DTO.cs create mode 100644 Data.SeaHavenIndustries/Models/Events.cs 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/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/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index d66f708..ea60e8e 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -66,6 +66,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/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/SeaHavenIndustries.sln b/SeaHavenIndustries.sln index 5560d76..b9df8e5 100644 --- a/SeaHavenIndustries.sln +++ b/SeaHavenIndustries.sln @@ -13,6 +13,8 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE 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 @@ - - + + From b2aeb35a55a0a918e3b42a9d5dad51f9a0c96ab7 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:56:46 -0400 Subject: [PATCH 06/16] Add starter unit tests for validators, DTO mappers, and PagedResult (#9) --- SeaHaven.Services.Tests/PagedResultTests.cs | 34 ++++++ .../SeaHaven.Services.Tests.csproj | 23 ++++ SeaHaven.Services.Tests/ValidationTests.cs | 104 ++++++++++++++++++ .../WorkOrderMappingTests.cs | 74 +++++++++++++ SeaHavenIndustries.sln | 6 + 5 files changed, 241 insertions(+) create mode 100644 SeaHaven.Services.Tests/PagedResultTests.cs create mode 100644 SeaHaven.Services.Tests/SeaHaven.Services.Tests.csproj create mode 100644 SeaHaven.Services.Tests/ValidationTests.cs create mode 100644 SeaHaven.Services.Tests/WorkOrderMappingTests.cs 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/SeaHavenIndustries.sln b/SeaHavenIndustries.sln index b9df8e5..429aed6 100644 --- a/SeaHavenIndustries.sln +++ b/SeaHavenIndustries.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.DataServices", "Se 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +47,10 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 568961642f6e1db9b3c692d526a67ab2a0a5ab37 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 30 Jun 2026 13:20:40 -0300 Subject: [PATCH 07/16] feat(work-orders): add domain enums and aggregate root fields --- .../Enums/WorkOrderEnums.cs | 263 ++++++++++++------ Data.SeaHavenIndustries/Models/Dispatch.cs | 5 + .../Models/WorkOrderAuditLog.cs | 4 + .../Models/WorkOrderContacts.cs | 2 +- .../Models/WorkOrderFieldLock.cs | 18 ++ Data.SeaHavenIndustries/Models/WorkerOrder.cs | 69 +++-- 6 files changed, 251 insertions(+), 110 deletions(-) create mode 100644 Data.SeaHavenIndustries/Models/WorkOrderFieldLock.cs diff --git a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs index 9c2e454..b8db7fc 100644 --- a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs +++ b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs @@ -1,92 +1,171 @@ -namespace Data.SeaHavenIndustries.Enums -{ - public enum WorkOrderStatus - { - Open = 1, - InProgress = 2, - Completed = 3, - Cancelled = 4, - OnHold = 5 - } - - public enum WorkOrderPriority - { - Low = 1, - Medium = 2, - High = 3, - Critical = 4 - } - - public enum AuditAction - { - Create = 1, - Edit = 2, - Delete = 3, - StatusChange = 4, - AssignmentChange = 5, - Dispatch = 6, - DispatchUpdate = 7, - DispatchVerify = 8 - } - - public enum ContactType - { - Primary = 1, - Secondary = 2, - Billing = 3, - Technical = 4 - } - - public enum AssetStatus - { - Active = 1, - Inactive = 2, - UnderMaintenance = 3, - Retired = 4 - } - - public enum DispatchStatus - { - Pending = 1, - Assigned = 2, - InProgress = 3, - Completed = 4, - Cancelled = 5 - } - - public enum FollowUpStatus - { - Open = 1, - InProgress = 2, - Resolved = 3, - Closed = 4 - } - - public enum PMScheduleFrequency - { - Daily = 1, - Weekly = 2, - Monthly = 3, - Quarterly = 4, - SemiAnnual = 5, - Annual = 6 - } - - public enum CommentType - { - General = 1, - Internal = 2, - Customer = 3, - Vendor = 4, - System = 5 - } - - public enum RecordType - { - WorkOrder = 1, - Dispatch = 2, - Quote = 3, - Contact = 4, - Asset = 5 - } -} +namespace Data.SeaHavenIndustries.Enums +{ + /// 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/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs index 869fedf..831cc17 100644 --- a/Data.SeaHavenIndustries/Models/Dispatch.cs +++ b/Data.SeaHavenIndustries/Models/Dispatch.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Data.SeaHavenIndustries @@ -26,6 +27,10 @@ namespace Data.SeaHavenIndustries public string? AcceptToken { get; set; } public string? VerifiedBy { get; set; } public DateTime? VerifiedAt { get; set; } + + [Timestamp] + public byte[]? RowVersion { get; set; } + public ICollection? Comments { get; set; } public ICollection? DispatchWorkOrders { get; set; } public ICollection? ChecklistItems { get; set; } diff --git a/Data.SeaHavenIndustries/Models/WorkOrderAuditLog.cs b/Data.SeaHavenIndustries/Models/WorkOrderAuditLog.cs index 0b2572f..b3fbb7d 100644 --- a/Data.SeaHavenIndustries/Models/WorkOrderAuditLog.cs +++ b/Data.SeaHavenIndustries/Models/WorkOrderAuditLog.cs @@ -15,6 +15,10 @@ namespace Data.SeaHavenIndustries public string? OldValue { get; set; } public string? NewValue { get; set; } public string? Action { get; set; } + public string? EventType { get; set; } + public string? ActorType { get; set; } + public int? DispatchId { get; set; } + public string? CorrelationId { get; set; } public DateTime CreatedAt { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/WorkOrderContacts.cs b/Data.SeaHavenIndustries/Models/WorkOrderContacts.cs index 96e89eb..52919ea 100644 --- a/Data.SeaHavenIndustries/Models/WorkOrderContacts.cs +++ b/Data.SeaHavenIndustries/Models/WorkOrderContacts.cs @@ -12,6 +12,6 @@ namespace Data.SeaHavenIndustries public int? WorkorderId { get; set; } [ForeignKey(nameof(WorkorderId))] public virtual WorkOrder? WorkOrder { get; set; } - + public string? Notes { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/WorkOrderFieldLock.cs b/Data.SeaHavenIndustries/Models/WorkOrderFieldLock.cs new file mode 100644 index 0000000..c749159 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/WorkOrderFieldLock.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + /// + /// ManualEditFlag — blocks Sync overwrite after first SHOC edit per field. + /// + public class WorkOrderFieldLock + { + public int Id { get; set; } + public int WorkOrderId { get; set; } + [ForeignKey(nameof(WorkOrderId))] + public virtual WorkOrder? WorkOrder { get; set; } + public string FieldName { get; set; } = ""; + public DateTime LockedAt { get; set; } + public string? LockedByUserId { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 6d3692e..a5038d7 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -1,11 +1,16 @@ -using System; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using static System.Runtime.InteropServices.JavaScript.JSType; +using Data.SeaHavenIndustries.Enums; namespace Data.SeaHavenIndustries { + /// + /// 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 --- public string? InternalWONumber { get; set; } public string? ExternalWorkOrderId { get; set; } public string? WorkerOrderNumber { get; set; } @@ -15,34 +20,59 @@ namespace Data.SeaHavenIndustries public string? SiteCode { get; set; } public string? Building { get; set; } public string? Severity { get; set; } - public DateTime? DateReported { get; set; } + public DateTime? DueDate { get; set; } + public string? AssignTo { get; set; } + [ForeignKey(nameof(AssignTo))] + public virtual ApplicationUser? AssignToUser { get; set; } + public int? LocationId { get; set; } + [ForeignKey(nameof(LocationId))] + public virtual Locations? Locations { get; set; } + public LifecycleStatus? LifecycleStatus { get; set; } + public string? LegacyStatus { get; set; } + public WorkOrderType? WorkOrderType { get; set; } + /// 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; } + + // --- Scheduling slice --- + public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledStart { get; set; } + public DateTime? ScheduledEnd { get; set; } + public DateOnly? TargetWeek { get; set; } + public bool? ScheduleWeekOnly { get; set; } + + // --- Tracking slice (set-once) --- + public DateOnly? OriginalWeek { get; set; } + public DateOnly? OriginalDate { get; set; } + + // --- Analytics slice --- + public int RescheduleCount { get; set; } + public int CarriedOver { get; set; } + public OperationalFlags OperationalFlags { get; set; } + + // --- Completion slice --- + public DocStatus? DocStatus { get; set; } + + // --- Legacy SQL columns (drift reconciliation) --- + public string? AvettaTask { get; set; } + public DateOnly? AssignDate { get; set; } + + // --- Legacy fields (retained for Blazor / coexistence) --- + public DateTime? DateReported { get; set; } public string? SourceEmailS3Key { get; set; } public string? Problem { get; set; } public string? Trade { get; set; } public string? SubTrade { get; set; } public decimal? VendorNTE { get; set; } - public DateTime? ScheduledDate { get; set; } public DateTime? CompletedDate { get; set; } public string? Source { get; set; } public bool? istemplate { get; set; } public string? PO { get; set; } public string? TT { get; set; } - //public int? ContactId { get; set; } - //[ForeignKey(nameof(ContactId))] - //public virtual Contacts? POC { get; set; } - public string? AssignTo { get; set; } - [ForeignKey(nameof(AssignTo))] - public virtual ApplicationUser? AssignToUser { get; set; } - public DateTime? DueDate { get; set; } public string? Priority { get; set; } public string? Status { get; set; } - public int? LocationId { get; set; } - [ForeignKey(nameof(LocationId))] - public virtual Locations? Locations { get; set; } - //public int? CategoryId { get; set; } - //[ForeignKey(nameof(CategoryId))] - //public virtual Category? Category { get; set; } public string? Attachments { get; set; } public string? BeforPhotoIssue { get; set; } public string? BeforPhotoAttachment { get; set; } @@ -51,11 +81,16 @@ namespace Data.SeaHavenIndustries public string? SignOffName { get; set; } public string? SignOffAttachment { get; set; } public string? SignOffSignature { get; set; } + + [Timestamp] + public byte[]? RowVersion { get; set; } + public ICollection? Comments { get; set; } public ICollection? Quotes { get; set; } public ICollection? WorkOrderContacts { get; set; } public ICollection? workOrderAttachments { get; set; } public ICollection? WorkOrderCategories { get; set; } public ICollection? Dispatches { get; set; } + public ICollection? FieldLocks { get; set; } } } From e244c32bc9b794780b5c1509b5ec741289d64742 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 30 Jun 2026 13:20:41 -0300 Subject: [PATCH 08/16] feat(work-orders): add EF config and Phase 0 migration --- .../Auth/ApplicationDbContext.cs | 21 + ...163145_Phase0_DomainFoundation.Designer.cs | 2991 +++++++++++++++++ .../20260624163145_Phase0_DomainFoundation.cs | 744 ++++ .../ApplicationDbContextModelSnapshot.cs | 249 +- 4 files changed, 3896 insertions(+), 109 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.cs diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index ea60e8e..a34d5d4 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -33,6 +33,24 @@ namespace Data.SeaHavenIndustries .HasIndex(u => new { u.DispatchId, u.Status }) .IsUnique() .HasFilter("[Status] = 'Pending'"); + + builder.Entity() + .HasIndex(l => new { l.WorkOrderId, l.FieldName }) + .IsUnique(); + + builder.Entity() + .Property(w => w.RowVersion) + .IsRowVersion(); + + builder.Entity() + .Property(d => d.RowVersion) + .IsRowVersion(); + + builder.Entity() + .HasOne(w => w.PrimaryDispatch) + .WithMany() + .HasForeignKey(w => w.PrimaryDispatchId) + .OnDelete(DeleteBehavior.Restrict); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -47,6 +65,7 @@ namespace Data.SeaHavenIndustries public DbSet workOrderCategories { get; set; } public DbSet workOrderAttachments { get; set; } public DbSet WorkOrderAuditLogs { get; set; } + public DbSet WorkOrderFieldLocks { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } public DbSet VendorAccessTokens { get; set; } @@ -88,6 +107,8 @@ namespace Data.SeaHavenIndustries public DateTime? DeletedDate { get; set; } public bool? IsDeleted { get; set; } public string? UserImage { get; set; } + public string? Initials { get; set; } + public string? Color { get; set; } public int? Type { get; set; } // 1 for users 0 for admin public ICollection