shoc-backend/Api.SeaHavenIndustries/Controllers/VendorController.cs
Alexandre Brandizzi e5cf4cec09 merge: integrate origin/dev into PR #22 flag-color base
Brings in dev's Phase 5 (PR #17) + vendor PRs (#25/#28/#29) atop the
Phase 6/7 + flagColor base (PR #22). Preserves dev Phase 1-5 behavior and
PR #22 Phase 6/7 + flagColor behavior.

Conflict resolutions (16 files):
- Migrations Phase4_SearchIndexes/.Designer + Phase5_DomainEvents/.Designer:
  take dev (Phase4 incl. SQL Server SiteCode/InternalWONumber index-compat
  shrink fix; Phase5 identical). ModelSnapshot union: Vendor CompanyId index
  + Phase7 ServiceNotes/ExternalWorkOrderId index.
- ApplicationDbContext: keep dev SiteCode/InternalWONumber MaxLength (Phase1-5
  + unguarded model test) + HEAD CompletionDocTemplate/ExternalWorkOrderId.
- WorkOrderAuditService: unify on dev async staging API; convert Phase6
  CompletionService 2 call sites to await StageFieldChangedAsync (drops
  HEAD sync duplicate; only callers, no test refs).
- Hosted services: take HEAD (retry-on-failure, coherent with Phase7
  WorkOrderJobRunStateAccessor/OpsHealth). Program.cs keeps dev vendor DI
  (ClamAV/VendorDocumentScanWorker/ArgumentExceptionFilter) + HEAD Phase7.
- WorkOrderController: keep HEAD Phase6/7 service params + dev doc comment.
- VendorController/WorkOrderBoardCreateService/QueryFilters/appsettings:
  union / dev-correct.
- WorkOrderBoardUpdateServiceTests: union of HEAD (Phase6/7+flagColor) and
  dev (Phase1-5) test methods.

Verified WorkOrderType.Other (enum 99) is a legit category, not an overdue
sentinel; overdue uses dedicated OperationalFlags.PastDue + IsPastDue, and
'Overdue' is rejected as a WorkOrderType (no PR #23 import needed).

Removed dev duplicate Api.Options.WorkOrderJobRunState (HEAD defines it in
Services.Implementation alongside the Accessor; Services cannot reference Api).
2026-07-24 14:20:16 -03:00

477 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
[Route("api/vendors")]
public class VendorController : Controller
{
private readonly IVendorService _vendorService;
private readonly ApplicationDbContext _db; // Keep for complex queries like nearby vendors
private readonly Helper.ZipCodeDistance _zipDistance;
private readonly Helper.VendorPortalTokenService _vendorTokens;
private readonly IConfiguration _config;
public VendorController(IVendorService vendorService, ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config)
{
_vendorService = vendorService;
_db = db;
_zipDistance = zipDistance;
_vendorTokens = vendorTokens;
_config = config;
}
[HttpGet("GetVendorList")]
public async Task<IActionResult> GetVendorList(
string? search = "",
int page = 1,
int pageSize = 10,
bool? isActive = true,
[FromQuery] string[]? companies = null,
[FromQuery] string[]? trades = null,
[FromQuery] string[]? locations = null,
[FromQuery] string[]? jobBuckets = null)
{
var pagedResult = await _vendorService.GetVendorsPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets);
var data = pagedResult.Items.Select(v => new
{
v.Id,
CompanyName = v.Name,
v.CompanyId,
v.ContactName,
v.Email,
v.Phone,
v.CompanyPhone,
v.PreferredContact,
v.Address,
v.City,
v.State,
Zip = v.Zipcode,
v.TradeSpecialties,
v.GoogleMapsUrl,
v.Notes,
v.IsActive,
v.TotalJobs
});
return Ok(new Pagination_DTO
{
Data = data,
PageNumber = page,
PageSize = pageSize,
TotalCount = pagedResult.TotalCount,
TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize)
});
}
[HttpGet("{id}")]
[HttpGet("GetById")]
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
{
var vendorId = id ?? queryId;
if (vendorId == null)
return BadRequest(new Response { Status = "Error", Message = "Id is required" });
var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value);
if (vendor == null)
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
return Ok(new
{
vendor.Id,
CompanyName = vendor.Name,
vendor.CompanyId,
vendor.ContactName,
vendor.Email,
vendor.Phone,
vendor.CompanyPhone,
vendor.PreferredContact,
vendor.Address,
vendor.City,
vendor.State,
Zip = vendor.Zipcode,
vendor.TradeSpecialties,
vendor.GoogleMapsUrl,
vendor.Notes,
vendor.IsActive,
vendor.TotalJobs
});
}
[HttpPost]
[HttpPost("Create")]
public async Task<IActionResult> Create([FromBody] Vendor_DTO model)
{
try
{
var createDto = new CreateVendorDTO
{
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"),
CompanyId = model.CompanyId,
ContactName = model.ContactName,
Email = model.Email,
Phone = model.Phone,
CompanyPhone = model.CompanyPhone,
PreferredContact = model.PreferredContact,
Address = model.Address,
City = model.City,
State = model.State,
Zipcode = model.Zip,
TradeSpecialties = model.TradeSpecialties,
GoogleMapsUrl = model.GoogleMapsUrl,
Notes = model.Notes,
IsActive = model.IsActive ?? true
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
await _vendorService.CreateVendorAsync(createDto, userId);
return Ok(new DataResponse { Message = "Vendor Created", Status = "200" });
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPut("{id}")]
[HttpPost("Update")]
public async Task<IActionResult> Update([FromRoute] int? id, [FromBody] EditVendor_DTO model)
{
try
{
// For named route, id comes from model
int vendorId = id ?? model.Id;
if (vendorId == 0)
return BadRequest(new Response { Status = "Error", Message = "Vendor Id is required" });
var updateDto = new UpdateVendorDTO
{
Name = model.CompanyName,
CompanyId = model.CompanyId,
ContactName = model.ContactName,
Email = model.Email,
Phone = model.Phone,
CompanyPhone = model.CompanyPhone,
PreferredContact = model.PreferredContact,
Address = model.Address,
City = model.City,
State = model.State,
Zipcode = model.Zip,
TradeSpecialties = model.TradeSpecialties,
GoogleMapsUrl = model.GoogleMapsUrl,
Notes = model.Notes,
IsActive = model.IsActive
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
await _vendorService.UpdateVendorAsync(vendorId, updateDto, userId);
return Ok(new DataResponse { Message = "Vendor Updated", Status = "200" });
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (VendorDeactivationBlockedException dbex)
{
return Conflict(new
{
Status = "Conflict",
Message = dbex.Message,
OpenWorkOrders = dbex.OpenWorkOrders
});
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpDelete("{id}")]
[HttpPost("Delete")]
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null)
{
try
{
// Support both route parameter and query string
int vendorId = id ?? queryId ?? 0;
if (vendorId == 0)
return BadRequest(new Response { Status = "Error", Message = "Vendor Id is required" });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
await _vendorService.DeleteVendorAsync(vendorId, userId);
return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" });
}
catch (VendorDeactivationBlockedException dbex)
{
return Conflict(new
{
Status = "Conflict",
Message = dbex.Message,
OpenWorkOrders = dbex.OpenWorkOrders
});
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet("{id:int}/deactivation-impact")]
public async Task<IActionResult> GetDeactivationImpact(int id)
{
try
{
var impact = await _vendorService.GetDeactivationImpactAsync(id);
return Ok(impact);
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPut("{id:int}/work-order-update")]
public async Task<IActionResult> UpdateFromWorkOrder(int id, [FromBody] WorkOrderVendorUpdate_DTO model)
{
try
{
if (model == null || model.WorkOrderId <= 0)
return BadRequest(new Response { Status = "Error", Message = "A valid workOrderId is required" });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
var dto = new WorkOrderVendorUpdateDTO
{
WorkOrderId = model.WorkOrderId,
ContactName = model.ContactName,
PreferredContact = model.PreferredContact,
Phone = model.Phone,
Email = model.Email,
Notes = model.Notes
};
var result = await _vendorService.UpdateVendorFromWorkOrderAsync(id, dto, userId);
return Ok(result);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor or work order link not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet("Dropdown")]
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null)
{
var query = _db.Vendors.Where(v => v.IsActive);
var vendors = await query.OrderBy(v => v.CompanyName)
.Select(v => new
{
v.Id,
v.CompanyName,
v.ContactName,
v.PreferredContact,
v.TradeSpecialties,
v.Address,
v.City,
v.State,
v.Zip
})
.ToListAsync();
var result = vendors.Select(v =>
{
var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip);
var addr = new[] { v.Address, v.City, v.State, v.Zip }
.Where(s => !string.IsNullOrWhiteSpace(s));
return new
{
v.Id,
v.CompanyName,
v.ContactName,
v.PreferredContact,
v.TradeSpecialties,
address = string.Join(", ", addr),
distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
};
}).ToList();
if (!string.IsNullOrWhiteSpace(trade))
{
var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList();
var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList();
result = tradeMatched.Concat(rest).ToList();
}
if (!string.IsNullOrWhiteSpace(siteZip))
{
result = result.OrderBy(v => v.distanceMiles ?? 99999).ToList();
}
return Ok(result);
}
[HttpGet("facets")]
public async Task<IActionResult> GetFacets([FromQuery] bool? isActive = null)
{
var query = _db.Vendors
.AsNoTracking()
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
if (isActive.HasValue)
query = query.Where(v => v.IsActive == isActive.Value);
var vendors = await query
.OrderByDescending(v => v.Id)
.ToListAsync();
var companies = vendors
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(v => v.CompanyName)
.Select(v => new
{
Name = v.CompanyName,
v.CompanyId,
v.CompanyPhone,
v.Email,
v.PreferredContact,
v.Address,
v.City,
v.State,
Zip = v.Zip,
v.GoogleMapsUrl
});
var trades = vendors
.SelectMany(v => (v.TradeSpecialties ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value);
var locations = vendors
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
.Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) })
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);
return Ok(new
{
Companies = companies,
Trades = trades,
Locations = locations,
JobBuckets = new[]
{
new { Id = "under-50", Label = "Under 50" },
new { Id = "50-99", Label = "50–99" },
new { Id = "100-149", Label = "100–149" },
new { Id = "150-plus", Label = "150+" }
}
});
}
[HttpGet("{id:int}/portal-token")]
public async Task<IActionResult> GetPortalToken(int id)
{
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
var token = await _vendorTokens.GetOrCreateActiveTokenAsync(id);
return Ok(BuildPortalTokenResponse(token));
}
[HttpPost("{id:int}/portal-token/rotate")]
public async Task<IActionResult> RotatePortalToken(int id)
{
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
var token = await _vendorTokens.RotateAsync(id);
return Ok(BuildPortalTokenResponse(token));
}
[HttpPost("{id:int}/portal-token/revoke")]
public async Task<IActionResult> RevokePortalToken(int id)
{
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
await _vendorTokens.RevokeAllAsync(id);
return Ok(new { revoked = true });
}
private object BuildPortalTokenResponse(VendorAccessToken token)
{
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
return new
{
token.Token,
token.IssuedAt,
token.ExpiresAt,
token.LastUsedAt,
PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard"
};
}
}
}