shoc-backend/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs
Adam Moussa 73be673444 Add vendor portal with token-based authentication
- VendorAccessToken model + unique-index migration; TokenLifetimeDays config
- VendorPortalTokenService: CSPRNG token generation, rotation, revocation
- VendorPortalController: public portal API guarded by X-Vendor-Token header;
  dispatches list/detail, accept, vendor status transitions, cancel request,
  checklist updates, signoffs (vendor + customer), comments with dispatcher
  attribution via AspNetUsers join
- VendorController: portal-token admin endpoints (get / rotate / revoke)
- WorkOrderController: dispatch email now uses vendor portal URL and HTML-encodes
  user fields; AddDispatchComment now stores CommentType='dispatcher' with the
  SHOC user's name so portal can attribute the author
- DispatchPublicController: deprecated per-dispatch GET accept flow returns a
  static 'link no longer active' page (no state mutation)
2026-04-20 12:01:53 -04:00

491 lines
20 KiB
C#

using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
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;
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens)
{
_db = db;
_tokens = tokens;
}
[HttpGet("session")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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 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.Address,
dispatch.WorkOrder.Locations.City,
dispatch.WorkOrder.Locations.State,
dispatch.WorkOrder.Locations.ZipCode
},
Checklist = checklist,
Signoffs = signoffs,
Comments = comments
}
});
}
[HttpPost("dispatches/{id:int}/accept")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 }
});
}
private async Task<Vendor?> ResolveVendorAsync()
{
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
var token = values.ToString();
return await _tokens.ResolveVendorAsync(token);
}
private Task<Dispatch?> 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; }
}
}
}