feat(services): add global services registry

This commit is contained in:
Alexandre Brandizzi 2026-09-16 16:18:46 -03:00
parent 4b772c8db3
commit 06f9450485
15 changed files with 5894 additions and 0 deletions

View file

@ -0,0 +1,185 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class ServicesRegistryControllerTests
{
private static ServicesRegistryController NewController(Mock<IServicesRegistryService> service, params string[] roles)
{
var controller = new ServicesRegistryController(service.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"))
}
}
};
return controller;
}
private static ServicesRegistryValidationErrorDto? ErrorValue(IActionResult result) =>
(result as ObjectResult)?.Value as ServicesRegistryValidationErrorDto;
[Fact]
public async Task GetAll_ReturnsOkWithServices()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ServiceDto> { new() { Id = 1, Name = "Leak", IsActive = true } });
var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
(ok.Value as IEnumerable<ServiceDto>).Should().ContainSingle(s => s.Name == "Leak");
}
[Fact]
public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException(
"WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency."));
var result = await NewController(service, "Admin").GetAll(null, WorkOrderType.Project, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
ErrorValue(bad)!.Code.Should().Be("WorkOrderTypeInvalid");
ErrorValue(bad)!.Message.Should().Be("Supported work order types may only include PM, Reactive, or Emergency.");
}
[Fact]
public async Task GetById_WhenMissing_ReturnsNotFoundWithCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.GetByIdAsync(9, It.IsAny<CancellationToken>()))
.ReturnsAsync((ServiceDto?)null);
var result = await NewController(service, "Admin").GetById(9, CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
ErrorValue(notFound)!.Code.Should().Be("NotFound");
}
[Fact]
public async Task Create_PassesCallerAndReturnsCreated()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServiceDto { Id = 42, Name = "Leak", IsActive = true });
var result = await NewController(service, "Scheduler").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
var created = result.Should().BeOfType<ObjectResult>().Subject;
created.StatusCode.Should().Be(StatusCodes.Status201Created);
(created.Value as ServiceDto)!.Id.Should().Be(42);
service.Verify(s => s.CreateAsync(
It.Is<ClaimsPrincipal>(p => p.IsInRole("Scheduler")),
It.Is<ServiceInput>(i => i.Name == "Leak"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Create_WhenForbidden_Returns403WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."));
var result = await NewController(service, "User").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenDuplicateName_Returns400WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."));
var result = await NewController(service, "Admin").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
ErrorValue(bad)!.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Update_ReturnsOkWithUpdatedService()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 7, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ServiceDto { Id = 7, Name = "Renamed" });
var result = await NewController(service, "Admin").Update(7, new ServiceInput { Name = "Renamed" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
(ok.Value as ServiceDto)!.Name.Should().Be("Renamed");
}
[Fact]
public async Task Update_WhenNotFound_Returns404WithCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
ErrorValue(notFound)!.Code.Should().Be("NotFound");
}
[Fact]
public async Task Deactivate_ReturnsOk()
{
var service = new Mock<IServicesRegistryService>();
var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
service.Verify(s => s.DeactivateAsync(
It.Is<ClaimsPrincipal>(p => p.IsInRole("Admin")), 3, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Deactivate_WhenForbidden_Returns403WithStableCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 3, It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services."));
var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Deactivate_WhenNotFound_Returns404WithCode()
{
var service = new Mock<IServicesRegistryService>();
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<CancellationToken>()))
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
}

View file

@ -0,0 +1,364 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
// Global Services registry behavior. The registry has no tenant scope
// (no AccountId), authorization is enforced at the service boundary, and the
// only allowed supported work-order types are PM, Reactive, and Emergency.
public class ServicesRegistryServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ServicesRegistryService NewService(ApplicationDbContext ctx) =>
new(new ServicesRegistryDataService(ctx));
private static ClaimsPrincipal Principal(params string[] roles) =>
new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"));
private static ClaimsPrincipal Admin() => Principal("Admin");
private static ClaimsPrincipal Scheduler() => Principal("Scheduler");
private static ServiceInput Input(
string name = "Leak",
string? trade = "Plumbing & Water Systems",
string? iconKey = "plumbing-water-systems",
bool requiresDoc = false,
int? templateId = null,
List<WorkOrderType>? types = null) => new()
{
Name = name,
Trade = trade,
IconKey = iconKey,
RequiresCompletionDocument = requiresDoc,
CompletionDocTemplateId = templateId,
SupportedWorkOrderTypes = types ?? new List<WorkOrderType>
{
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
}
};
private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) =>
new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted };
[Fact]
public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes()
{
using var ctx = NewContext();
var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None);
dto.Id.Should().BeGreaterThan(0);
dto.Name.Should().Be("Leak Detection");
dto.Trade.Should().Be("Plumbing & Water Systems");
dto.IconKey.Should().Be("plumbing-water-systems");
dto.IsActive.Should().BeTrue("new services are always active");
dto.CompletionDocTemplate.Should().BeNull();
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency });
var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single();
row.NormalizedName.Should().Be("leak detection");
}
[Fact]
public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes()
{
using var ctx = NewContext();
var input = Input(types: null);
input.SupportedWorkOrderTypes = null;
var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[]
{
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
});
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name)
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Subject.Single();
ex.Code.Should().Be("NameRequired");
}
[Fact]
public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TradeInvalid");
}
[Theory]
[InlineData(" hvac ")]
[InlineData("HVAC")]
[InlineData("hvac")]
public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade)
{
using var ctx = NewContext();
var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None);
dto.Trade.Should().Be("HVAC");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey)
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("IconKeyInvalid");
}
[Fact]
public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing()
{
using var ctx = NewContext();
var service = NewService(ctx);
await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter()
{
using var ctx = NewContext();
var service = NewService(ctx);
// Distinct actors with different role sets still share one global registry.
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
var all = await service.GetAllAsync(null, null, CancellationToken.None);
all.Should().ContainSingle(s => s.Id == first.Id);
typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions");
}
[Fact]
public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid()
{
using var ctx = NewContext();
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
ctx.CompletionDocTemplates.Add(Template("Inactive", active: false));
ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true));
ctx.SaveChanges();
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
}
[Fact]
public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary()
{
using var ctx = NewContext();
var template = Template("Completion Packet");
ctx.CompletionDocTemplates.Add(template);
ctx.SaveChanges();
var dto = await NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None);
dto.RequiresCompletionDocument.Should().BeTrue();
dto.CompletionDocTemplate.Should().NotBeNull();
dto.CompletionDocTemplate!.Id.Should().Be(template.Id);
dto.CompletionDocTemplate.Name.Should().Be("Completion Packet");
}
[Fact]
public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink()
{
using var ctx = NewContext();
var template = Template("Completion Packet");
ctx.CompletionDocTemplates.Add(template);
ctx.SaveChanges();
var dto = await NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None);
dto.RequiresCompletionDocument.Should().BeFalse();
dto.CompletionDocTemplate.Should().BeNull();
ctx.Services.Single().CompletionDocTemplateId.Should().BeNull();
}
[Theory]
[InlineData(WorkOrderType.PO)]
[InlineData(WorkOrderType.Project)]
[InlineData(WorkOrderType.Inspection)]
[InlineData(WorkOrderType.AddOn)]
[InlineData(WorkOrderType.Other)]
public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported)
{
using var ctx = NewContext();
var input = Input(types: new List<WorkOrderType> { WorkOrderType.PM, unsupported });
var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
}
[Fact]
public async Task Update_RewritesFieldsButPreservesActiveState()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
var updated = await service.UpdateAsync(
Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None);
updated.Name.Should().Be("Leak Repair");
updated.Trade.Should().Be("HVAC");
updated.IconKey.Should().Be("hvac");
updated.IsActive.Should().BeFalse("update preserves the persisted active state");
}
[Fact]
public async Task Update_WhenNotFound_ThrowsNotFound()
{
using var ctx = NewContext();
var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
}
[Fact]
public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName()
{
using var ctx = NewContext();
var service = NewService(ctx);
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None);
var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
ctx.Services.Single().Name.Should().Be("Leak");
}
[Fact]
public async Task Deactivate_SoftStateChangeOnly_PreservesRow()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
ctx.Services.Should().HaveCount(1, "deactivation never hard deletes");
var byId = await service.GetByIdAsync(created.Id, CancellationToken.None);
byId.Should().NotBeNull();
byId!.IsActive.Should().BeFalse();
}
[Fact]
public async Task Deactivate_WhenScheduler_ThrowsForbidden()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
ctx.Services.Single().IsActive.Should().BeTrue();
}
[Fact]
public async Task Deactivate_WhenNotFound_ThrowsNotFound()
{
using var ctx = NewContext();
var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
}
[Fact]
public async Task GetAll_FiltersByActiveStateAndWorkOrderType()
{
using var ctx = NewContext();
var service = NewService(ctx);
var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List<WorkOrderType> { WorkOrderType.PM }), CancellationToken.None);
var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List<WorkOrderType> { WorkOrderType.Reactive }), CancellationToken.None);
await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None);
(await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { pm.Id });
(await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { reactive.Id });
(await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { pm.Id });
(await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None))
.Should().BeEmpty();
}
[Fact]
public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid()
{
using var ctx = NewContext();
var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
}
[Fact]
public async Task GetById_WhenMissing_ReturnsNull()
{
using var ctx = NewContext();
(await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull();
}
}

View file

@ -0,0 +1,110 @@
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/services")]
public class ServicesRegistryController : Controller
{
private readonly IServicesRegistryService _servicesRegistryService;
public ServicesRegistryController(IServicesRegistryService servicesRegistryService)
{
_servicesRegistryService = servicesRegistryService;
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool? isActive, [FromQuery] WorkOrderType? workOrderType, CancellationToken cancellationToken)
{
try
{
var services = await _servicesRegistryService.GetAllAsync(isActive, workOrderType, cancellationToken);
return Ok(services);
}
catch (ServicesRegistryValidationException ex)
{
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
var service = await _servicesRegistryService.GetByIdAsync(id, cancellationToken);
if (service == null)
return NotFound(new ServicesRegistryValidationErrorDto { Code = "NotFound", Message = "Service not found." });
return Ok(service);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] ServiceInput input, CancellationToken cancellationToken)
{
try
{
var created = await _servicesRegistryService.CreateAsync(User, input, cancellationToken);
return StatusCode(StatusCodes.Status201Created, created);
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
{
return MapForbidden(ex);
}
catch (ServicesRegistryValidationException ex)
{
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] ServiceInput input, CancellationToken cancellationToken)
{
try
{
var updated = await _servicesRegistryService.UpdateAsync(User, id, input, cancellationToken);
return Ok(updated);
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
{
return MapForbidden(ex);
}
catch (ServicesRegistryValidationException ex)
{
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPost("{id}/deactivate")]
public async Task<IActionResult> Deactivate(int id, CancellationToken cancellationToken)
{
try
{
await _servicesRegistryService.DeactivateAsync(User, id, cancellationToken);
return Ok(new { message = "Service deactivated" });
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
{
return MapForbidden(ex);
}
}
private IActionResult MapForbidden(ServicesRegistryValidationException ex)
{
return StatusCode(StatusCodes.Status403Forbidden,
new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
}

View file

@ -206,6 +206,53 @@ namespace Data.SeaHavenIndustries
builder.Entity<Trade>()
.HasIndex(t => t.NormalizedName)
.IsUnique();
// Bounded lengths keep the unique NormalizedName key SQL
// Server-indexable (nvarchar(max) is not).
builder.Entity<Service>()
.Property(s => s.Name)
.HasMaxLength(200);
builder.Entity<Service>()
.Property(s => s.NormalizedName)
.HasMaxLength(200);
builder.Entity<Service>()
.HasIndex(s => s.NormalizedName)
.IsUnique();
builder.Entity<Service>()
.Property(s => s.Trade)
.HasMaxLength(64);
builder.Entity<Service>()
.HasIndex(s => s.Trade);
builder.Entity<Service>()
.Property(s => s.IconKey)
.HasMaxLength(64);
builder.Entity<Service>()
.HasIndex(s => s.IsActive);
builder.Entity<Service>()
.HasOne(s => s.CompletionDocTemplate)
.WithMany()
.HasForeignKey(s => s.CompletionDocTemplateId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<Service>()
.HasIndex(s => s.CompletionDocTemplateId);
builder.Entity<ServiceWorkOrderType>()
.HasIndex(t => new { t.ServiceId, t.WorkOrderType })
.IsUnique();
builder.Entity<ServiceWorkOrderType>()
.HasOne(t => t.Service)
.WithMany(s => s.SupportedWorkOrderTypes)
.HasForeignKey(t => t.ServiceId)
.OnDelete(DeleteBehavior.Restrict);
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -241,6 +288,8 @@ namespace Data.SeaHavenIndustries
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }
public DbSet<Service> Services { get; set; }
public DbSet<ServiceWorkOrderType> ServiceWorkOrderTypes { get; set; }
// New DbSets for added entities
public DbSet<Accounts> Accounts { get; set; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH303_ServicesRegistry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Services",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
NormalizedName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Trade = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
IconKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
RequiresCompletionDocument = table.Column<bool>(type: "bit", nullable: false),
CompletionDocTemplateId = table.Column<int>(type: "int", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Services", x => x.Id);
table.ForeignKey(
name: "FK_Services_CompletionDocTemplates_CompletionDocTemplateId",
column: x => x.CompletionDocTemplateId,
principalTable: "CompletionDocTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ServiceWorkOrderTypes",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ServiceId = table.Column<int>(type: "int", nullable: false),
WorkOrderType = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ServiceWorkOrderTypes", x => x.Id);
table.ForeignKey(
name: "FK_ServiceWorkOrderTypes_Services_ServiceId",
column: x => x.ServiceId,
principalTable: "Services",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Services_CompletionDocTemplateId",
table: "Services",
column: "CompletionDocTemplateId");
migrationBuilder.CreateIndex(
name: "IX_Services_IsActive",
table: "Services",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_Services_NormalizedName",
table: "Services",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Services_Trade",
table: "Services",
column: "Trade");
migrationBuilder.CreateIndex(
name: "IX_ServiceWorkOrderTypes_ServiceId_WorkOrderType",
table: "ServiceWorkOrderTypes",
columns: new[] { "ServiceId", "WorkOrderType" },
unique: true);
// Additive seed: one active service per active Problem dropdown
// value already stored in the database (never the prototype's demo
// leaves). Idempotent via NormalizedName NOT EXISTS; dropdown rows are
// only read, never altered. Repeated source values collapse onto a
// deterministic MIN(Value) representative per normalized key.
migrationBuilder.Sql(
"""
INSERT INTO [Services] ([Name], [NormalizedName], [Trade], [IconKey], [RequiresCompletionDocument], [CompletionDocTemplateId], [IsActive], [IsDeleted], [createdby])
SELECT seeded.[Name], seeded.[NormalizedName], seeded.[Trade],
CASE seeded.[Trade]
WHEN N'Plumbing & Water Systems' THEN N'plumbing-water-systems'
WHEN N'Electrical' THEN N'electrical'
WHEN N'HVAC' THEN N'hvac'
WHEN N'Fire & Life Safety' THEN N'fire-life-safety'
ELSE N'general-building-handyman'
END,
0, NULL, 1, 0, N'sh303-migration'
FROM (
SELECT agg.[Name], agg.[NormalizedName],
CASE
WHEN agg.[Name] IN (N'Leak', N'Clogged') THEN N'Plumbing & Water Systems'
WHEN agg.[Name] = N'No Power' THEN N'Electrical'
WHEN agg.[Name] IN (N'Not Cooling', N'Not Heating') THEN N'HVAC'
WHEN agg.[Name] = N'Alarm Fault' THEN N'Fire & Life Safety'
ELSE N'General Building & Handyman'
END AS [Trade]
FROM (
SELECT MIN(src.[Value]) AS [Name],
LOWER(LTRIM(RTRIM(src.[Value]))) AS [NormalizedName]
FROM [DropdownOptions] src
WHERE src.[Category] = N'Problem'
AND src.[IsActive] = 1
GROUP BY LOWER(LTRIM(RTRIM(src.[Value])))
) agg
) seeded
WHERE NOT EXISTS (
SELECT 1 FROM [Services] s WHERE s.[NormalizedName] = seeded.[NormalizedName]
);
INSERT INTO [ServiceWorkOrderTypes] ([ServiceId], [WorkOrderType], [IsDeleted])
SELECT s.[Id], types.[WorkOrderType], 0
FROM [Services] s
CROSS JOIN (VALUES (2), (6), (3)) AS types([WorkOrderType])
WHERE s.[createdby] = N'sh303-migration'
AND NOT EXISTS (
SELECT 1 FROM [ServiceWorkOrderTypes] x
WHERE x.[ServiceId] = s.[Id]
AND x.[WorkOrderType] = types.[WorkOrderType]
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ServiceWorkOrderTypes");
migrationBuilder.DropTable(
name: "Services");
}
}
}

View file

@ -1762,6 +1762,121 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Regions");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("CompletionDocTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("IconKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("RequiresCompletionDocument")
.HasColumnType("bit");
b.Property<string>("Trade")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompletionDocTemplateId");
b.HasIndex("IsActive");
b.HasIndex("NormalizedName")
.IsUnique();
b.HasIndex("Trade");
b.ToTable("Services");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<int>("ServiceId")
.HasColumnType("int");
b.Property<int>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("ServiceId", "WorkOrderType")
.IsUnique();
b.ToTable("ServiceWorkOrderTypes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.Property<int>("Id")
@ -3521,6 +3636,27 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Quotes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
{
b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate")
.WithMany()
.HasForeignKey("CompletionDocTemplateId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CompletionDocTemplate");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b =>
{
b.HasOne("Data.SeaHavenIndustries.Service", "Service")
.WithMany("SupportedWorkOrderTypes")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
@ -3889,6 +4025,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Employees");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
{
b.Navigation("SupportedWorkOrderTypes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
{
b.Navigation("Items");

View file

@ -0,0 +1,29 @@
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
// Global (tenant-scope free) Services registry. Services are never
// hard deleted; deactivation is a soft IsActive state change so historical
// work-order references remain resolvable.
public class Service : FullAuditEntity
{
public string Name { get; set; } = "";
public string NormalizedName { get; set; } = "";
public string Trade { get; set; } = "";
public string IconKey { get; set; } = "";
public bool RequiresCompletionDocument { get; set; }
public int? CompletionDocTemplateId { get; set; }
public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; }
public bool IsActive { get; set; } = true;
public virtual ICollection<ServiceWorkOrderType> SupportedWorkOrderTypes { get; set; } = new List<ServiceWorkOrderType>();
}
// Supported work-order types (PM, Reactive, Emergency) stored as an
// explicitly queryable relation so work-order features can filter by type.
public class ServiceWorkOrderType : FullAuditEntity
{
public int ServiceId { get; set; }
public virtual Service? Service { get; set; }
public WorkOrderType WorkOrderType { get; set; }
}
}

View file

@ -0,0 +1,130 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class ServicesRegistryDataService : IServicesRegistryDataService
{
private readonly ApplicationDbContext _context;
public ServicesRegistryDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<ServiceRegistryRow>> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
{
var query = _context.Services
.AsNoTracking()
.Where(s => s.IsDeleted != true);
if (isActive.HasValue)
query = query.Where(s => s.IsActive == isActive.Value);
if (workOrderType.HasValue)
query = query.Where(s => s.SupportedWorkOrderTypes.Any(t => t.WorkOrderType == workOrderType.Value));
var rows = await query
.OrderBy(s => s.Name)
.Select(s => new ServiceRegistryRow
{
Id = s.Id,
Name = s.Name,
Trade = s.Trade,
IconKey = s.IconKey,
RequiresCompletionDocument = s.RequiresCompletionDocument,
IsActive = s.IsActive,
CompletionDocTemplateId = s.CompletionDocTemplateId,
CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null,
SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList()
})
.ToListAsync(cancellationToken);
return rows;
}
public async Task<ServiceRegistryRow?> GetServiceRowByIdAsync(int id, CancellationToken cancellationToken)
{
return await _context.Services.AsNoTracking()
.Where(s => s.Id == id && s.IsDeleted != true)
.Select(s => new ServiceRegistryRow
{
Id = s.Id,
Name = s.Name,
Trade = s.Trade,
IconKey = s.IconKey,
RequiresCompletionDocument = s.RequiresCompletionDocument,
IsActive = s.IsActive,
CompletionDocTemplateId = s.CompletionDocTemplateId,
CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null,
SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList()
})
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<Service?> GetServiceForUpdateAsync(int id, CancellationToken cancellationToken)
{
return await _context.Services
.Include(s => s.SupportedWorkOrderTypes)
.FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken);
}
public async Task<bool> NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken)
{
return await _context.Services
.AnyAsync(s => s.IsDeleted != true
&& s.NormalizedName == normalizedName
&& (excludeServiceId == null || s.Id != excludeServiceId),
cancellationToken);
}
public async Task<LinkedTemplateSummary?> GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken)
{
return await _context.CompletionDocTemplates
.Where(t => t.Id == templateId && t.IsActive && t.IsDeleted != true)
.Select(t => new LinkedTemplateSummary { Id = t.Id, Name = t.Name })
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<Service> AddServiceAsync(Service service, CancellationToken cancellationToken)
{
await _context.Services.AddAsync(service, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return service;
}
public async Task SaveServiceAsync(Service service, IReadOnlyCollection<WorkOrderType> supportedWorkOrderTypes, CancellationToken cancellationToken)
{
var desired = supportedWorkOrderTypes.Distinct().ToList();
var current = service.SupportedWorkOrderTypes ?? new List<ServiceWorkOrderType>();
var stale = current.Where(t => !desired.Contains(t.WorkOrderType)).ToList();
_context.ServiceWorkOrderTypes.RemoveRange(stale);
var existingTypes = current.Select(t => t.WorkOrderType).ToHashSet();
foreach (var type in desired.Where(t => !existingTypes.Contains(t)))
{
_context.ServiceWorkOrderTypes.Add(new ServiceWorkOrderType
{
ServiceId = service.Id,
WorkOrderType = type
});
}
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> DeactivateServiceAsync(int id, CancellationToken cancellationToken)
{
var service = await _context.Services
.FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken);
if (service == null)
return false;
service.IsActive = false;
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
}

View file

@ -0,0 +1,39 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
// Read-side projection for the global Services registry. The linked
// completion template is projected to id/name only — full template
// entities are never materialized.
public class ServiceRegistryRow
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Trade { get; set; } = "";
public string IconKey { get; set; } = "";
public bool RequiresCompletionDocument { get; set; }
public bool IsActive { get; set; }
public int? CompletionDocTemplateId { get; set; }
public string? CompletionDocTemplateName { get; set; }
public List<WorkOrderType> SupportedWorkOrderTypes { get; set; } = new();
}
public class LinkedTemplateSummary
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public interface IServicesRegistryDataService
{
Task<IReadOnlyList<ServiceRegistryRow>> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken);
Task<ServiceRegistryRow?> GetServiceRowByIdAsync(int id, CancellationToken cancellationToken);
Task<Service?> GetServiceForUpdateAsync(int id, CancellationToken cancellationToken);
Task<bool> NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken);
Task<LinkedTemplateSummary?> GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken);
Task<Service> AddServiceAsync(Service service, CancellationToken cancellationToken);
Task SaveServiceAsync(Service service, IReadOnlyCollection<WorkOrderType> supportedWorkOrderTypes, CancellationToken cancellationToken);
Task<bool> DeactivateServiceAsync(int id, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,38 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs
{
public class ServiceDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Trade { get; set; } = "";
public string IconKey { get; set; } = "";
public bool RequiresCompletionDocument { get; set; }
public LinkedTemplateSummaryDto? CompletionDocTemplate { get; set; }
public bool IsActive { get; set; }
public List<WorkOrderType> SupportedWorkOrderTypes { get; set; } = new();
}
public class LinkedTemplateSummaryDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public class ServiceInput
{
public string? Name { get; set; }
public string? Trade { get; set; }
public string? IconKey { get; set; }
public bool RequiresCompletionDocument { get; set; }
public int? CompletionDocTemplateId { get; set; }
public List<WorkOrderType>? SupportedWorkOrderTypes { get; set; }
}
public class ServicesRegistryValidationErrorDto
{
public string Code { get; set; } = "";
public string Message { get; set; } = "";
}
}

View file

@ -0,0 +1,12 @@
namespace SeaHaven.Services.Exceptions
{
public class ServicesRegistryValidationException : Exception
{
public ServicesRegistryValidationException(string code, string message) : base(message)
{
Code = code;
}
public string Code { get; }
}
}

View file

@ -0,0 +1,226 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class ServicesRegistryService : IServicesRegistryService
{
// Product contract: the registry only accepts the three work-order types
// the product currently supports. Any other WorkOrderType is rejected.
public static readonly IReadOnlySet<WorkOrderType> AllowedWorkOrderTypes =
new HashSet<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency };
private static readonly WorkOrderType[] DefaultSupportedWorkOrderTypes =
{ WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency };
public const string SchedulerRole = "Scheduler";
public const string AdminRole = "Admin";
private const int NameMaxLength = 200;
private const int IconKeyMaxLength = 64;
private readonly IServicesRegistryDataService _dataService;
public ServicesRegistryService(IServicesRegistryDataService dataService)
{
_dataService = dataService;
}
public async Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
{
ValidateWorkOrderTypeFilter(workOrderType);
var rows = await _dataService.GetServiceRowsAsync(isActive, workOrderType, cancellationToken);
return rows.Select(MapToDto).ToList();
}
public async Task<ServiceDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken);
return row == null ? null : MapToDto(row);
}
public async Task<ServiceDto> CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken)
{
EnsureCanMutate(user);
var name = RequireValidName(input.Name);
var trade = RequireCanonicalTrade(input.Trade);
var iconKey = RequireValidIconKey(input.IconKey);
var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes);
var template = await ResolveTemplateLinkAsync(input, cancellationToken);
if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), null, cancellationToken))
throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.");
var service = new Service
{
Name = name,
NormalizedName = TradeCatalog.Normalize(name),
Trade = trade,
IconKey = iconKey,
RequiresCompletionDocument = input.RequiresCompletionDocument,
CompletionDocTemplateId = template?.Id,
IsActive = true,
SupportedWorkOrderTypes = supportedTypes
.Select(t => new ServiceWorkOrderType { WorkOrderType = t })
.ToList()
};
await _dataService.AddServiceAsync(service, cancellationToken);
var row = await _dataService.GetServiceRowByIdAsync(service.Id, cancellationToken);
return MapToDto(row!);
}
public async Task<ServiceDto> UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken)
{
EnsureCanMutate(user);
var service = await _dataService.GetServiceForUpdateAsync(id, cancellationToken);
if (service == null || service.IsDeleted == true)
throw new ServicesRegistryValidationException("NotFound", "Service not found.");
var name = RequireValidName(input.Name);
var trade = RequireCanonicalTrade(input.Trade);
var iconKey = RequireValidIconKey(input.IconKey);
var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes);
var template = await ResolveTemplateLinkAsync(input, cancellationToken);
if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), id, cancellationToken))
throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.");
service.Name = name;
service.NormalizedName = TradeCatalog.Normalize(name);
service.Trade = trade;
service.IconKey = iconKey;
service.RequiresCompletionDocument = input.RequiresCompletionDocument;
service.CompletionDocTemplateId = template?.Id;
await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken);
var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken);
return MapToDto(row!);
}
public async Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
{
if (user is null || !user.IsInRole(AdminRole))
throw new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services.");
var deactivated = await _dataService.DeactivateServiceAsync(id, cancellationToken);
if (!deactivated)
throw new ServicesRegistryValidationException("NotFound", "Service not found.");
}
private static void EnsureCanMutate(ClaimsPrincipal user)
{
if (user is null || !(user.IsInRole(SchedulerRole) || user.IsInRole(AdminRole)))
throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required.");
}
private static string RequireValidName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ServicesRegistryValidationException("NameRequired", "Service name is required.");
var trimmed = name.Trim();
if (trimmed.Length > NameMaxLength)
throw new ServicesRegistryValidationException("NameInvalid", $"Service name cannot exceed {NameMaxLength} characters.");
return trimmed;
}
private static string RequireCanonicalTrade(string? trade)
{
if (string.IsNullOrWhiteSpace(trade))
throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values.");
var normalized = TradeCatalog.Normalize(trade);
var canonical = TradeCatalog.CanonicalTrades
.FirstOrDefault(t => TradeCatalog.Normalize(t) == normalized);
if (canonical == null)
throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values.");
return canonical;
}
private static string RequireValidIconKey(string? iconKey)
{
if (string.IsNullOrWhiteSpace(iconKey))
throw new ServicesRegistryValidationException("IconKeyInvalid", "Icon key is required.");
var trimmed = iconKey.Trim();
if (trimmed.Length > IconKeyMaxLength)
throw new ServicesRegistryValidationException("IconKeyInvalid", $"Icon key cannot exceed {IconKeyMaxLength} characters.");
return trimmed;
}
private static List<WorkOrderType> RequireValidSupportedTypes(IEnumerable<WorkOrderType>? supportedTypes)
{
// The registry form does not expose type categories yet. Until it
// does, an omitted or empty category list keeps a service available
// to the three supported work-order types.
var types = supportedTypes?.Distinct().ToList();
if (types == null || types.Count == 0)
return DefaultSupportedWorkOrderTypes.ToList();
if (types.Any(t => !AllowedWorkOrderTypes.Contains(t)))
throw new ServicesRegistryValidationException(
"WorkOrderTypeInvalid",
"Supported work order types may only include PM, Reactive, or Emergency.");
return types;
}
private static void ValidateWorkOrderTypeFilter(WorkOrderType? workOrderType)
{
if (workOrderType.HasValue && !AllowedWorkOrderTypes.Contains(workOrderType.Value))
throw new ServicesRegistryValidationException(
"WorkOrderTypeInvalid",
"Supported work order types may only include PM, Reactive, or Emergency.");
}
private async Task<LinkedTemplateSummary?> ResolveTemplateLinkAsync(ServiceInput input, CancellationToken cancellationToken)
{
// Toggle off clears/ignores any template link.
if (!input.RequiresCompletionDocument || input.CompletionDocTemplateId is not int templateId)
return null;
var template = await _dataService.GetActiveTemplateSummaryAsync(templateId, cancellationToken);
if (template == null)
throw new ServicesRegistryValidationException(
"TemplateInvalid",
"Completion document template must reference an active, non-deleted template.");
return template;
}
private static ServiceDto MapToDto(ServiceRegistryRow row)
{
return new ServiceDto
{
Id = row.Id,
Name = row.Name,
Trade = row.Trade,
IconKey = row.IconKey,
RequiresCompletionDocument = row.RequiresCompletionDocument,
CompletionDocTemplate = row.CompletionDocTemplateId == null
? null
: new LinkedTemplateSummaryDto
{
Id = row.CompletionDocTemplateId.Value,
Name = row.CompletionDocTemplateName ?? ""
},
IsActive = row.IsActive,
SupportedWorkOrderTypes = row.SupportedWorkOrderTypes.OrderBy(t => (int)t).ToList()
};
}
}
}

View file

@ -0,0 +1,19 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
// Global Services registry. Tenant scope is global (no AccountId),
// matching DropdownOptions. Reads are authenticated; create/update require
// the Scheduler or Admin role and deactivation is Admin-only, enforced at
// this service entry — never in the controller alone.
public interface IServicesRegistryService
{
Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken);
Task<ServiceDto?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<ServiceDto> CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken);
Task<ServiceDto> UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken);
Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,316 @@
using System.Reflection;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Migrations;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Xunit;
namespace SeaHavenIndustries.Tests;
// Shape evidence for the global Services registry migration (table,
// indexes, FK, idempotent additive seed) plus a SQL Server execution test that
// is silently skipped when no provider is reachable, mirroring existing
// provider-gated migration tests.
public class SH303ServicesRegistryMigrationTests
{
private const string LocalDbMaster =
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
[Fact]
public void Up_CreatesRegistryTablesUniqueIndexesFkAndIdempotentProblemSeed()
{
var operations = BuildOperations("Up");
var createTables = operations.OfType<CreateTableOperation>().ToList();
Assert.Equal(
new HashSet<string> { "Services", "ServiceWorkOrderTypes" },
createTables.Select(t => t.Name).ToHashSet());
var services = createTables.Single(t => t.Name == "Services");
Assert.Contains(services.Columns, c => c.Name == "NormalizedName" && (string)c.ColumnType! == "nvarchar(200)");
Assert.Contains(services.Columns, c => c.Name == "CompletionDocTemplateId" && c.IsNullable);
Assert.Contains(services.Columns, c => c.Name == "RequiresCompletionDocument" && !c.IsNullable);
Assert.Contains(services.Columns, c => c.Name == "IsActive" && !c.IsNullable);
var foreignKeys = services.ForeignKeys.Concat(
createTables.Single(t => t.Name == "ServiceWorkOrderTypes").ForeignKeys)
.ToList();
Assert.Contains(foreignKeys, fk =>
fk.Table == "Services"
&& fk.PrincipalTable == "CompletionDocTemplates"
&& fk.OnDelete == ReferentialAction.Restrict);
Assert.Contains(foreignKeys, fk =>
fk.Table == "ServiceWorkOrderTypes" && fk.PrincipalTable == "Services");
var indexes = operations.OfType<CreateIndexOperation>().ToList();
Assert.Contains(indexes, i =>
i.Table == "Services" && i.Columns.SequenceEqual(new[] { "NormalizedName" }) && i.IsUnique);
Assert.Contains(indexes, i =>
i.Table == "ServiceWorkOrderTypes"
&& i.Columns.SequenceEqual(new[] { "ServiceId", "WorkOrderType" })
&& i.IsUnique);
// The seed is raw additive SQL: read-only over DropdownOptions, idempotent
// via NormalizedName NOT EXISTS, and never mutates dropdown rows.
var seedSql = string.Join("\n", operations.OfType<SqlOperation>().Select(o => o.Sql));
Assert.Contains("[Category] = N'Problem'", seedSql);
Assert.Contains("[IsActive] = 1", seedSql);
Assert.Contains("NOT EXISTS", seedSql);
Assert.Contains("INSERT INTO [Services]", seedSql);
Assert.Contains("INSERT INTO [ServiceWorkOrderTypes]", seedSql);
Assert.DoesNotContain("UPDATE [DropdownOptions]", seedSql);
Assert.DoesNotContain("DELETE FROM [DropdownOptions]", seedSql);
Assert.DoesNotContain("DELETE [DropdownOptions]", seedSql);
}
[Fact]
public void Down_DropsRegistryTables()
{
var operations = BuildOperations("Down");
var drops = operations.OfType<DropTableOperation>().Select(d => d.Name).ToList();
Assert.Equal(
new HashSet<string> { "Services", "ServiceWorkOrderTypes" },
drops.ToHashSet());
Assert.Empty(operations.OfType<SqlOperation>());
}
[Fact]
public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable()
{
var masterConnectionString = await ResolveMasterConnectionStringAsync();
if (masterConnectionString == null)
return;
var dbName = $"SH303Registry_{Guid.NewGuid():N}";
var connectionString = WithDatabase(masterConnectionString, dbName);
try
{
await CreateDatabaseAsync(masterConnectionString, dbName);
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using (var seedSource = connection.CreateCommand())
{
seedSource.CommandText =
"""
CREATE TABLE DropdownOptions (
Id int NOT NULL IDENTITY PRIMARY KEY,
Category nvarchar(max) NOT NULL,
Value nvarchar(max) NOT NULL,
ParentValue nvarchar(max) NULL,
SortOrder int NOT NULL,
IsActive bit NOT NULL
);
CREATE TABLE CompletionDocTemplates (
Id int NOT NULL IDENTITY PRIMARY KEY,
Name nvarchar(200) NOT NULL,
ServiceKey nvarchar(max) NOT NULL,
TemplateUrl nvarchar(max) NOT NULL,
IsActive bit NOT NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL
);
INSERT INTO DropdownOptions (Category, Value, SortOrder, IsActive) VALUES
(N'Problem', N'Leak', 0, 1),
(N'Problem', N'leak', 1, 1),
(N'Problem', N'No Power', 2, 1),
(N'Problem', N'Not Cooling', 3, 1),
(N'Problem', N'Not Heating', 4, 1),
(N'Problem', N'Alarm Fault', 5, 1),
(N'Problem', N'Broken/Damaged', 6, 1),
(N'Problem', N'Retired Ghost', 7, 0),
(N'Trade', N'Electrical', 0, 1);
""";
await seedSource.ExecuteNonQueryAsync();
}
await using var context = new ApplicationDbContext(
new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlServer(connection).Options);
await ApplyMigrationAsync(context, new SH303_ServicesRegistry());
await using (var servicesCmd = connection.CreateCommand())
{
servicesCmd.CommandText =
"""
SELECT [Name], [Trade], [IconKey]
FROM [Services]
ORDER BY [Name];
""";
await using var reader = await servicesCmd.ExecuteReaderAsync();
var rows = new List<string>();
while (await reader.ReadAsync())
rows.Add($"{reader.GetString(0)}|{reader.GetString(1)}|{reader.GetString(2)}");
// Repeated case variants collapse onto one deterministic row and the
// inactive value plus the non-Problem category never seed.
var expected = new[]
{
"Alarm Fault|Fire & Life Safety|fire-life-safety",
"Broken/Damaged|General Building & Handyman|general-building-handyman",
"Leak|Plumbing & Water Systems|plumbing-water-systems",
"No Power|Electrical|electrical",
"Not Cooling|HVAC|hvac",
"Not Heating|HVAC|hvac"
};
Assert.Equal(expected, rows.OrderBy(r => r).ToArray());
}
await using (var typesCmd = connection.CreateCommand())
{
typesCmd.CommandText =
"""
SELECT COUNT(*)
FROM [ServiceWorkOrderTypes] t
JOIN [Services] s ON s.[Id] = t.[ServiceId]
WHERE t.[WorkOrderType] IN (2, 3, 6);
""";
Assert.Equal(18, Convert.ToInt32(await typesCmd.ExecuteScalarAsync())); // 6 services x 3 supported types
}
await using (var untouchedCmd = connection.CreateCommand())
{
untouchedCmd.CommandText = "SELECT COUNT(*) FROM [DropdownOptions];";
Assert.Equal(9, Convert.ToInt32(await untouchedCmd.ExecuteScalarAsync()));
}
// Re-running only the seed statements must be a no-op (idempotence).
var before = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];");
await ApplySeedStatementsAsync(context);
var after = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];");
Assert.Equal(before, after);
var migration = new SH303_ServicesRegistry();
await ApplyMigrationAsync(context, migration, "Down");
Assert.Equal(0, await CountAsync(connection, "SELECT COUNT(*) FROM sys.tables WHERE name IN ('Services', 'ServiceWorkOrderTypes');"));
await ApplyMigrationAsync(context, migration, "Up");
Assert.Equal(6, await CountAsync(connection, "SELECT COUNT(*) FROM [Services];"));
Assert.Equal(18, await CountAsync(connection, "SELECT COUNT(*) FROM [ServiceWorkOrderTypes];"));
}
finally
{
await DropDatabaseAsync(masterConnectionString, dbName);
}
}
private static List<MigrationOperation> BuildOperations(string direction)
{
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(new SH303_ServicesRegistry(), [builder]);
return builder.Operations;
}
private static async Task ApplyMigrationAsync(ApplicationDbContext context, Migration migration, string direction = "Up")
{
var builder = new MigrationBuilder(context.Database.ProviderName!);
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(migration, [builder]);
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(builder.Operations, context.Model);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task ApplySeedStatementsAsync(ApplicationDbContext context)
{
var sqlOperations = BuildOperations("Up").OfType<SqlOperation>().ToList();
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(sqlOperations, context.Model);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task<int> CountAsync(SqlConnection connection, string commandText)
{
await using var command = connection.CreateCommand();
command.CommandText = commandText;
return Convert.ToInt32(await command.ExecuteScalarAsync());
}
private static async Task<string?> ResolveMasterConnectionStringAsync()
{
var configured = Environment.GetEnvironmentVariable("SH303_SQLSERVER_MASTER");
var candidate = !string.IsNullOrWhiteSpace(configured)
? WithDatabase(configured, "master")
: OperatingSystem.IsWindows() ? LocalDbMaster : null;
if (candidate == null)
return null;
try
{
await using var connection = new SqlConnection(candidate);
await connection.OpenAsync();
return candidate;
}
catch (SqlException)
{
return null;
}
catch (InvalidOperationException)
{
return null;
}
}
private static async Task CreateDatabaseAsync(string masterConnectionString, string dbName)
{
await using var connection = new SqlConnection(masterConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE {QuoteSqlServerIdentifier(dbName)};";
await command.ExecuteNonQueryAsync();
}
private static async Task DropDatabaseAsync(string masterConnectionString, string dbName)
{
try
{
var quoted = QuoteSqlServerIdentifier(dbName);
await using var connection = new SqlConnection(masterConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText =
$"""
IF DB_ID(@dbName) IS NOT NULL
BEGIN
ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE {quoted};
END
""";
command.Parameters.AddWithValue("@dbName", dbName);
await command.ExecuteNonQueryAsync();
}
catch
{
// Best-effort cleanup for ephemeral SQL Server databases.
}
}
private static string QuoteSqlServerIdentifier(string name) =>
"[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
private static string WithDatabase(string connectionString, string database)
{
var builder = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = database
};
return builder.ConnectionString;
}
}