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 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 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 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 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 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 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 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 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 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 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 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 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("FrontendBaseUrl")?.TrimEnd('/') ?? ""; return new { token.Token, token.IssuedAt, token.ExpiresAt, token.LastUsedAt, PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard" }; } } }