shoc-backend/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs
Alexandre Brandizzi 8515676f4d feat(vendors): add admin-assigned vendor company Area
Seed an organization-wide Area catalogue (East, Central, West, California)
with stable ids, add a nullable AreaId to VendorCompany, allow only Admins
to change it through the roster endpoints, expose areas facet metadata and
an areas[n] company-directory filter with the __unassigned__ sentinel.
2026-09-16 11:34:45 -03:00

227 lines
9.2 KiB
C#

using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/vendor-company-roster")]
public class VendorCompanyRosterController : Controller
{
private readonly IVendorCompanyRosterService _rosterService;
private readonly ILogger<VendorCompanyRosterController> _logger;
public VendorCompanyRosterController(
IVendorCompanyRosterService rosterService,
ILogger<VendorCompanyRosterController> logger)
{
_rosterService = rosterService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> Get(
[FromQuery] int? vendorId,
[FromQuery] int? companyId,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
try
{
var roster = await _rosterService.GetRosterAsync(vendorId, companyId, userId, cancellationToken);
if (roster == null)
return NotFound(new Response { Status = "Error", Message = "Vendor roster not found" });
return Ok(roster);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateVendorRosterDTO model, CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
try
{
var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (VendorRosterDuplicateNameException dup)
{
return Conflict(new
{
Status = "Conflict",
Message = _logger.Sanitize(dup, "Another vendor company already uses that name."),
Code = "duplicate_vendor_company_name"
});
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPut("{companyId:int}")]
public async Task<IActionResult> Reconcile(
int companyId,
[FromBody] ReconcileVendorRosterDTO model,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
try
{
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
}
catch (VendorRosterConflictException conflict)
{
return Conflict(new
{
Status = "Conflict",
Message = _logger.Sanitize(conflict, "Vendor roster changes conflict with open work orders"),
BlockedWorkOrders = conflict.BlockedWorkOrders
});
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new
{
Status = "Conflict",
Message = "The vendor company was modified by another user. Refresh and retry.",
Code = 409
});
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
// Additive partial update (SH-250): inserts the submitted technicians and
// optionally updates company fields. A technician absent from the payload is
// never removed, so 409 here can only mean a stale row version.
[HttpPatch("{companyId:int}")]
public async Task<IActionResult> AddTechnicians(
int companyId,
[FromBody] AddTechniciansVendorRosterDTO model,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
try
{
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (VendorAreaAssignmentForbiddenException)
{
return StatusCode(
StatusCodes.Status403Forbidden,
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
}
catch (VendorRosterDuplicateNameException dup)
{
// SH-250: a colliding rename is a client conflict, not a server fault.
return Conflict(new
{
Status = "Conflict",
Message = _logger.Sanitize(dup, "Another vendor company already uses that name."),
Code = 409
});
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new
{
Status = "Conflict",
Message = "The vendor company was modified by another user. Refresh and retry.",
Code = 409
});
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
}
}