using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; namespace Api.SeaHavenIndustries.Controllers { [ApiController] [Route("api/vendor-portal")] [Route("api/vendorportal")] public class VendorPortalController : Controller { private const string TokenHeader = "X-Vendor-Token"; private static readonly string[] VendorViewableStatuses = new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" }; private readonly ApplicationDbContext _db; private readonly VendorPortalTokenService _tokens; private readonly IConfiguration _config; private readonly SendMessage _sendMessage; public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage) { _db = db; _tokens = tokens; _config = config; _sendMessage = sendMessage; } [HttpGet("session")] public async Task Session() { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); return Ok(new DataResponse { Status = "Success", Data = new { vendor.Id, vendor.CompanyName, vendor.ContactName, vendor.Email, vendor.Phone } }); } [HttpGet("dispatches")] public async Task ListDispatches([FromQuery] string? status = null) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var query = _db.Dispatches .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) .Where(d => d.VendorId == vendor.Id && (d.IsDeleted == null || d.IsDeleted == false)); if (!string.IsNullOrWhiteSpace(status)) { query = query.Where(d => d.Status == status); } var dispatches = await query .OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate) .Select(d => new { d.Id, d.DispatchNumber, d.PONumber, d.Status, d.NTEAmount, d.ScheduledDate, d.CompletedDate, d.DispatchedAt, d.AcknowledgedAt, WorkOrderTitle = d.WorkOrder!.WorkerOrderTitle, InternalWONumber = d.WorkOrder!.InternalWONumber, LocationName = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.Name : null, LocationCity = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.City : null, LocationState = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.State : null }) .ToListAsync(); return Ok(new DataResponse { Status = "Success", Data = dispatches }); } [HttpGet("dispatches/{id:int}")] public async Task GetDispatch(int id) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); var checklist = await _db.DispatchChecklistItems .Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false)) .OrderBy(c => c.SortOrder) .Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt }) .ToListAsync(); var signoffs = await _db.DispatchSignoffs .Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false)) .Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt }) .ToListAsync(); var rawUplifts = await (from u in _db.DispatchUpliftRequests where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false) join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs from dec in decs.DefaultIfEmpty() orderby u.CreatedDate descending select new { u.Id, u.CurrentNTE, u.RequestedNTE, u.VendorReason, u.Status, u.RequiredTier, u.RequestedByVendorName, u.CreatedDate, u.DecidedAt, u.DecisionNote, DecidedByFirstName = dec != null ? dec.FirstName : null, DecidedByLastName = dec != null ? dec.LastName : null }) .ToListAsync(); var upliftRequests = rawUplifts.Select(u => new { u.Id, u.CurrentNTE, u.RequestedNTE, u.VendorReason, u.Status, u.RequiredTier, u.RequestedByVendorName, RequestedAt = u.CreatedDate, u.DecidedAt, u.DecisionNote, DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() }).ToList(); var rawComments = await (from c in _db.Comments where c.DispatchId == id && c.CommentType != "internal" && (c.IsDeleted == null || c.IsDeleted == false) join u in _db.Users on c.UserId equals u.Id into users from u in users.DefaultIfEmpty() orderby c.CreatedDate select new { c.Id, c.Commenttext, c.Commenter, c.CommentType, c.CreatedDate, c.UserId, UserFirstName = u != null ? u.FirstName : null, UserLastName = u != null ? u.LastName : null }) .ToListAsync(); var comments = rawComments.Select(c => { var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); string resolvedType; string resolvedCommenter; if (hasShocUser) { resolvedType = "dispatcher"; resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Dispatcher"; } else if (c.CommentType == "customer") { resolvedType = "customer"; resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; } else { resolvedType = "vendor"; resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : vendor.CompanyName ?? "Vendor"; } return new { c.Id, c.Commenttext, Commenter = resolvedCommenter, CommentType = resolvedType, c.CreatedDate }; }).ToList(); return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.DispatchNumber, dispatch.PONumber, dispatch.Status, dispatch.NTEAmount, dispatch.Description, dispatch.ScheduledDate, dispatch.CompletedDate, dispatch.DispatchedAt, dispatch.AcknowledgedAt, WorkOrder = dispatch.WorkOrder == null ? null : new { dispatch.WorkOrder.Id, dispatch.WorkOrder.InternalWONumber, dispatch.WorkOrder.WorkerOrderTitle, dispatch.WorkOrder.Description, dispatch.WorkOrder.Priority, dispatch.WorkOrder.DueDate, dispatch.WorkOrder.Trade, dispatch.WorkOrder.SubTrade, dispatch.WorkOrder.Problem }, Location = dispatch.WorkOrder?.Locations == null ? null : new { dispatch.WorkOrder.Locations.Name, dispatch.WorkOrder.Locations.Address1, dispatch.WorkOrder.Locations.City, dispatch.WorkOrder.Locations.State, dispatch.WorkOrder.Locations.Zip }, Checklist = checklist, Signoffs = signoffs, Comments = comments, UpliftRequests = upliftRequests } }); } [HttpPost("dispatches/{id:int}/accept")] public async Task Accept(int id) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); if (dispatch.Status != "Sent") { return BadRequest(new Response { Status = "Error", Message = $"Cannot accept a dispatch with status '{dispatch.Status}'" }); } var now = DateTime.UtcNow; dispatch.Status = "Acknowledged"; dispatch.AcknowledgedAt = now; dispatch.LastModificationTime = now; _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Status", OldValue = "Sent", NewValue = "Acknowledged", Action = "vendor_accept", CreatedAt = now }); await _db.SaveChangesAsync(); return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.AcknowledgedAt } }); } [HttpPost("dispatches/{id:int}/status")] public async Task ChangeStatus(int id, [FromBody] ChangeStatusRequest body) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); var from = dispatch.Status; var to = body.Status; if (!IsAllowedVendorTransition(from, to)) { return BadRequest(new Response { Status = "Error", Message = $"Transition from '{from}' to '{to}' is not allowed" }); } var now = DateTime.UtcNow; dispatch.Status = to; dispatch.LastModificationTime = now; if (to == "Completed") dispatch.CompletedDate = now; _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Status", OldValue = from, NewValue = to, Action = "vendor_status_change", CreatedAt = now }); await _db.SaveChangesAsync(); return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.CompletedDate } }); } [HttpPost("dispatches/{id:int}/request-cancel")] public async Task RequestCancel(int id, [FromBody] RequestCancelRequest body) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") { return BadRequest(new Response { Status = "Error", Message = $"Cannot request cancel on a '{dispatch.Status}' dispatch" }); } var now = DateTime.UtcNow; var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim(); _db.Comments.Add(new Comments { DispatchId = id, WorkerOrderId = dispatch.WorkOrderId, Commenter = vendor.CompanyName, CommentType = "vendor", RecordType = "cancel_request", Commenttext = $"Vendor requested cancellation: {reason}", CreatedDate = now }); _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber}", OldValue = dispatch.Status, NewValue = "Cancel Requested", Action = "vendor_request_cancel", CreatedAt = now }); await _db.SaveChangesAsync(); return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" }); } [HttpPost("dispatches/{id:int}/checklist/{itemId:int}")] public async Task UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") { return BadRequest(new Response { Status = "Error", Message = "Dispatch is locked" }); } var item = await _db.DispatchChecklistItems .FirstOrDefaultAsync(c => c.Id == itemId && c.DispatchId == id); if (item == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); var now = DateTime.UtcNow; item.IsCompleted = body.IsCompleted; item.CompletedBy = body.IsCompleted ? vendor.CompanyName : null; item.CompletedAt = body.IsCompleted ? now : null; item.LastModificationTime = now; await _db.SaveChangesAsync(); return Ok(new DataResponse { Status = "Success", Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt } }); } [HttpPost("dispatches/{id:int}/signoff")] public async Task AddSignoff(int id, [FromBody] SignoffRequest body) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") { return BadRequest(new Response { Status = "Error", Message = "Signoffs only allowed on In Progress or Completed dispatches" }); } if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature)) { return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" }); } var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant(); if (signoffType != "vendor" && signoffType != "customer") { return BadRequest(new Response { Status = "Error", Message = "signoffType must be 'vendor' or 'customer'" }); } var existing = await _db.DispatchSignoffs .FirstOrDefaultAsync(s => s.DispatchId == id && s.SignoffType == signoffType); if (existing != null) { return BadRequest(new Response { Status = "Error", Message = $"A {signoffType} signoff already exists for this dispatch" }); } var now = DateTime.UtcNow; var signoff = new DispatchSignoff { DispatchId = id, SignoffType = signoffType, Name = body!.Name, Signature = body.Signature, SignatureMethod = body.SignatureMethod ?? "drawn", SignedAt = now, CreatedDate = now }; _db.DispatchSignoffs.Add(signoff); _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", OldValue = null, NewValue = $"{(signoffType == "customer" ? "Customer" : "Vendor")}: {body.Name}", Action = signoffType == "customer" ? "customer_signoff" : "vendor_signoff", CreatedAt = now }); await _db.SaveChangesAsync(); return Ok(new DataResponse { Status = "Success", Data = new { signoff.Id, signoff.SignedAt } }); } [HttpPost("dispatches/{id:int}/comments")] public async Task AddComment(int id, [FromBody] CommentRequest body) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); if (string.IsNullOrWhiteSpace(body?.CommentText)) { return BadRequest(new Response { Status = "Error", Message = "Comment cannot be empty" }); } var now = DateTime.UtcNow; var comment = new Comments { DispatchId = id, WorkerOrderId = dispatch.WorkOrderId, Commenter = vendor.CompanyName, CommentType = "vendor", RecordType = "comment", Commenttext = body!.CommentText, CreatedDate = now }; _db.Comments.Add(comment); await _db.SaveChangesAsync(); return Ok(new DataResponse { Status = "Success", Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate } }); } [HttpPost("dispatches/{id:int}/uplift-request")] public async Task RequestUplift(int id, [FromBody] UpliftRequestBody body) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") { return BadRequest(new Response { Status = "Error", Message = $"Cannot request uplift on a '{dispatch.Status}' dispatch" }); } if (body == null || body.RequestedNTE <= 0) { return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" }); } var current = dispatch.NTEAmount ?? 0m; if (body.RequestedNTE <= current) { return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than the current NTE" }); } var pendingExists = await _db.DispatchUpliftRequests .AnyAsync(u => u.DispatchId == id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false)); if (pendingExists) { return BadRequest(new Response { Status = "Error", Message = "A pending uplift request already exists for this dispatch" }); } var tier1Max = _config.GetValue("Approvals:UpliftTier1MaxUsd") ?? 2500m; var delta = body.RequestedNTE - current; var requiredTier = delta > tier1Max ? 2 : 1; var now = DateTime.UtcNow; var req = new DispatchUpliftRequest { DispatchId = id, CurrentNTE = current, RequestedNTE = body.RequestedNTE, VendorReason = string.IsNullOrWhiteSpace(body.Reason) ? null : body.Reason!.Trim(), Status = "Pending", RequiredTier = requiredTier, RequestedByVendorName = vendor.CompanyName, CreatedDate = now }; _db.DispatchUpliftRequests.Add(req); _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = $"${current:F2}", NewValue = $"${body.RequestedNTE:F2}", Action = "uplift_requested", CreatedAt = now }); await _db.SaveChangesAsync(); await NotifyDispatcherOfUpliftAsync(dispatch, req, vendor); return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status, req.RequiredTier, req.CurrentNTE, req.RequestedNTE } }); } [HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/cancel")] public async Task CancelUpliftRequest(int id, int requestId) { var vendor = await ResolveVendorAsync(); if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); var req = await _db.DispatchUpliftRequests .FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == id); if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); if (req.Status != "Pending") { return BadRequest(new Response { Status = "Error", Message = $"Cannot cancel a '{req.Status}' uplift request" }); } var now = DateTime.UtcNow; req.Status = "Cancelled"; req.DecidedAt = now; req.LastModificationTime = now; _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = "Pending", NewValue = "Cancelled", Action = "uplift_cancelled", CreatedAt = now }); await _db.SaveChangesAsync(); return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); } private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor) { try { var dispatchAudit = await _db.WorkOrderAuditLogs .Where(a => a.WorkOrderId == (dispatch.WorkOrderId ?? 0) && a.Action == "dispatch" && !string.IsNullOrEmpty(a.UserId)) .OrderByDescending(a => a.CreatedAt) .FirstOrDefaultAsync(); if (dispatchAudit == null || string.IsNullOrWhiteSpace(dispatchAudit.UserId)) return; var dispatcher = await _db.Users.FindAsync(dispatchAudit.UserId); if (dispatcher == null || string.IsNullOrWhiteSpace(dispatcher.Email)) return; var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; var workOrderLink = dispatch.WorkOrderId.HasValue ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" : frontendBase; var vendorName = System.Net.WebUtility.HtmlEncode(vendor.CompanyName ?? "Vendor"); var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendor.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; var body = $@"

Vendor Uplift Request

Dispatch: {dispatchNum}

Vendor: {vendorName}

Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Delta${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}
Required Approval{tierText}

Vendor Reason

{reason}

"; await _sendMessage.SendEMail(dispatcher.Email, subject, body); } catch { } } private async Task ResolveVendorAsync() { if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; var token = values.ToString(); return await _tokens.ResolveVendorAsync(token); } private Task LoadVendorDispatchAsync(int id, int vendorId) { return _db.Dispatches .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) .FirstOrDefaultAsync(d => d.Id == id && d.VendorId == vendorId && (d.IsDeleted == null || d.IsDeleted == false)); } private static bool IsAllowedVendorTransition(string? from, string? to) { if (from == "Acknowledged" && to == "In Progress") return true; if (from == "In Progress" && to == "Completed") return true; return false; } public class ChangeStatusRequest { public string? Status { get; set; } } public class RequestCancelRequest { public string? Reason { get; set; } } public class ChecklistUpdateRequest { public bool IsCompleted { get; set; } } public class SignoffRequest { public string? Name { get; set; } public string? Signature { get; set; } public string? SignatureMethod { get; set; } public string? SignoffType { get; set; } } public class CommentRequest { public string? CommentText { get; set; } } public class UpliftRequestBody { public decimal RequestedNTE { get; set; } public string? Reason { get; set; } } } }