mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
* feat(vendors): add directory filters and details API * fix(vendors): preserve omitted status * fix(vendors): align facet filtering * fix(vendors): address directory review findings
385 lines
14 KiB
C#
385 lines
14 KiB
C#
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.ContactName,
|
||
v.Email,
|
||
v.Phone,
|
||
v.CompanyPhone,
|
||
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.ContactName,
|
||
vendor.Email,
|
||
vendor.Phone,
|
||
vendor.CompanyPhone,
|
||
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"),
|
||
ContactName = model.ContactName,
|
||
Email = model.Email,
|
||
Phone = model.Phone,
|
||
CompanyPhone = model.CompanyPhone,
|
||
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,
|
||
ContactName = model.ContactName,
|
||
Email = model.Email,
|
||
Phone = model.Phone,
|
||
CompanyPhone = model.CompanyPhone,
|
||
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 (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 (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("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.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.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.CompanyPhone,
|
||
v.Email,
|
||
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"
|
||
};
|
||
}
|
||
}
|
||
}
|