Add dispatch detail modal backend + vendor reply sync

- Add DispatchNumber and CompletedDate to Dispatch model
- Add DispatchId to Comments for per-dispatch vendor threads
- GetDispatchById endpoint with vendor communication thread
- UpdateDispatch endpoint for status, NTE, dates, description
- AddDispatchComment endpoint — saves comment + sends email to vendor with sender name
- BackfillDispatchNumbers endpoint for existing dispatches
- SyncVendorReplies endpoint — pulls from DynamoDB VendorReplies table
- Fix reply-to address to include dispatch number
- Include sender name in dispatch and comment emails
This commit is contained in:
Adam Moussa 2026-04-17 14:01:03 -04:00
parent 17590daf9d
commit 63f76e9b2c
8 changed files with 2884 additions and 6 deletions

View file

@ -16,6 +16,7 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly AmazonDynamoDBClient _dynamo; private readonly AmazonDynamoDBClient _dynamo;
private const string WO_TABLE = "WorkOrders"; private const string WO_TABLE = "WorkOrders";
private const string COMMENTS_TABLE = "WorkOrderComments"; private const string COMMENTS_TABLE = "WorkOrderComments";
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
public SyncController(ApplicationDbContext db) public SyncController(ApplicationDbContext db)
{ {
@ -239,6 +240,104 @@ namespace Api.SeaHavenIndustries.Controllers
return Ok(new { updated = wosMissing.Count }); return Ok(new { updated = wosMissing.Count });
} }
[HttpPost("BackfillDispatchNumbers")]
public async Task<IActionResult> BackfillDispatchNumbers()
{
var missing = await _db.Dispatches
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
.OrderBy(d => d.Id)
.ToListAsync();
if (missing.Count == 0) return Ok(new { updated = 0 });
int next = 1;
var last = await _db.Dispatches
.Where(d => d.DispatchNumber != null && d.DispatchNumber != "")
.OrderByDescending(d => d.DispatchNumber)
.Select(d => d.DispatchNumber)
.FirstOrDefaultAsync();
if (last != null && last.StartsWith("DSP-") && int.TryParse(last.Substring(4), out var n))
next = n + 1;
foreach (var d in missing)
{
d.DispatchNumber = $"DSP-{next:D5}";
next++;
}
await _db.SaveChangesAsync();
return Ok(new { updated = missing.Count });
}
[HttpPost("VendorReplies")]
public async Task<IActionResult> SyncVendorReplies()
{
var synced = 0;
var created = 0;
var skipped = 0;
Dictionary<string, AttributeValue>? lastKey = null;
do
{
var request = new ScanRequest
{
TableName = VENDOR_REPLIES_TABLE,
Limit = 100,
ExclusiveStartKey = lastKey
};
var response = await _dynamo.ScanAsync(request);
foreach (var item in response.Items)
{
var replyId = GetString(item, "reply_id");
var woNumber = GetString(item, "internal_wo_number");
var dispatchNumber = GetString(item, "dispatch_number");
if (string.IsNullOrEmpty(replyId) || string.IsNullOrEmpty(woNumber))
continue;
var alreadyExists = await _db.Comments
.AnyAsync(c => c.ExternalCommentId == replyId);
if (alreadyExists) { skipped++; synced++; continue; }
var workOrder = await _db.workOrders
.FirstOrDefaultAsync(w => w.InternalWONumber == woNumber);
if (workOrder == null) { skipped++; continue; }
int? dispatchId = null;
if (!string.IsNullOrWhiteSpace(dispatchNumber))
{
var dispatch = await _db.Dispatches
.FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrder.Id);
dispatchId = dispatch?.Id;
}
var comment = new Comments
{
ExternalCommentId = replyId,
WorkerOrderId = workOrder.Id,
DispatchId = dispatchId,
Commenttext = GetString(item, "reply_text"),
Commenter = GetString(item, "sender_email"),
CommentType = "vendor",
RecordType = "vendor_reply",
CreatedDate = ParseDate(GetString(item, "received_at")) ?? DateTime.UtcNow,
};
_db.Comments.Add(comment);
created++;
synced++;
if (synced % 50 == 0)
await _db.SaveChangesAsync();
}
lastKey = response.LastEvaluatedKey;
} while (lastKey != null && lastKey.Count > 0);
await _db.SaveChangesAsync();
return Ok(new { synced, created, skipped });
}
[HttpPost("BackfillCommentTypes")] [HttpPost("BackfillCommentTypes")]
public async Task<IActionResult> BackfillCommentTypes() public async Task<IActionResult> BackfillCommentTypes()
{ {

View file

@ -672,15 +672,18 @@ namespace Api.SeaHavenIndustries.Controllers
}).ToList(), }).ToList(),
dispatches = s.Dispatches.Select(d => new { dispatches = s.Dispatches.Select(d => new {
d.Id, d.Id,
d.DispatchNumber,
d.PONumber, d.PONumber,
d.NTEAmount, d.NTEAmount,
d.Description, d.Description,
d.Status, d.Status,
d.ScheduledDate, d.ScheduledDate,
d.CompletedDate,
d.DispatchedAt, d.DispatchedAt,
d.EmailSent, d.EmailSent,
vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", vendorName = d.Vendor != null ? d.Vendor.CompanyName : "",
vendorEmail = d.Vendor != null ? d.Vendor.Email : "" vendorEmail = d.Vendor != null ? d.Vendor.Email : "",
vendorId = d.VendorId
}).ToList() }).ToList()
}).FirstOrDefaultAsync(); }).FirstOrDefaultAsync();
if (exist == null) if (exist == null)
@ -1042,7 +1045,18 @@ namespace Api.SeaHavenIndustries.Controllers
if (lastPO != null && lastPO.StartsWith("VPO-") && int.TryParse(lastPO.Substring(4), out var lastNum)) if (lastPO != null && lastPO.StartsWith("VPO-") && int.TryParse(lastPO.Substring(4), out var lastNum))
nextPO = lastNum + 1; nextPO = lastNum + 1;
var lastDSP = await _db.Dispatches
.Where(d => d.DispatchNumber != null)
.OrderByDescending(d => d.DispatchNumber)
.Select(d => d.DispatchNumber)
.FirstOrDefaultAsync();
int nextDSP = 1;
if (lastDSP != null && lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var lastDspNum))
nextDSP = lastDspNum + 1;
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var currentUser = userId != null ? await _db.Users.FindAsync(userId) : null;
var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : "";
var dispatches = new List<object>(); var dispatches = new List<object>();
foreach (var vendorId in model.VendorIds) foreach (var vendorId in model.VendorIds)
@ -1052,13 +1066,16 @@ namespace Api.SeaHavenIndustries.Controllers
var poNumber = $"VPO-{nextPO:D5}"; var poNumber = $"VPO-{nextPO:D5}";
nextPO++; nextPO++;
var dispatchNumber = $"DSP-{nextDSP:D5}";
nextDSP++;
var replyTo = $"wo-{workOrder.InternalWONumber}@int.seahaven.com"; var replyTo = $"wo-{workOrder.InternalWONumber}-{dispatchNumber}@int.seahaven.com";
var dispatch = new Dispatch var dispatch = new Dispatch
{ {
WorkOrderId = model.WorkOrderId, WorkOrderId = model.WorkOrderId,
VendorId = vendorId, VendorId = vendorId,
DispatchNumber = dispatchNumber,
PONumber = poNumber, PONumber = poNumber,
NTEAmount = model.NTEAmount, NTEAmount = model.NTEAmount,
Description = model.Description, Description = model.Description,
@ -1068,7 +1085,7 @@ namespace Api.SeaHavenIndustries.Controllers
ReplyToAddress = replyTo ReplyToAddress = replyTo
}; };
var subject = $"[WO-{workOrder.InternalWONumber}] {workOrder.WorkerOrderTitle}"; var subject = $"[WO-{workOrder.InternalWONumber}-{dispatchNumber}] {workOrder.WorkerOrderTitle}";
var locationName = workOrder.Locations?.Name ?? ""; var locationName = workOrder.Locations?.Name ?? "";
var locationAddr = workOrder.Locations?.Address ?? ""; var locationAddr = workOrder.Locations?.Address ?? "";
@ -1089,7 +1106,8 @@ namespace Api.SeaHavenIndustries.Controllers
<h3>Description</h3> <h3>Description</h3>
<p>{model.Description ?? workOrder.Description}</p> <p>{model.Description ?? workOrder.Description}</p>
<hr/> <hr/>
<p style='color:#999;font-size:12px;'>Reply to this email to communicate about this work order.</p>"; <br/><p style='color:#666;font-size:13px;'>{senderName}<br/>Sea Haven Industries</p>
<hr/><p style='color:#999;font-size:12px;'>Reply to this email to communicate about this work order.</p>";
var emailSent = await _sendMessage.SendDispatchEmail(vendor.Email, subject, html, replyTo); var emailSent = await _sendMessage.SendDispatchEmail(vendor.Email, subject, html, replyTo);
dispatch.EmailSent = emailSent; dispatch.EmailSent = emailSent;
@ -1107,7 +1125,7 @@ namespace Api.SeaHavenIndustries.Controllers
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
dispatches.Add(new { dispatch.Id, poNumber, vendorName = vendor.CompanyName, dispatch.Status }); dispatches.Add(new { dispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, dispatch.Status });
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@ -1129,19 +1147,158 @@ namespace Api.SeaHavenIndustries.Controllers
.Select(d => new .Select(d => new
{ {
d.Id, d.Id,
d.DispatchNumber,
d.PONumber, d.PONumber,
d.NTEAmount, d.NTEAmount,
d.Description, d.Description,
d.Status, d.Status,
d.ScheduledDate,
d.CompletedDate,
d.DispatchedAt, d.DispatchedAt,
d.EmailSent, d.EmailSent,
vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", vendorName = d.Vendor != null ? d.Vendor.CompanyName : "",
vendorEmail = d.Vendor != null ? d.Vendor.Email : "" vendorEmail = d.Vendor != null ? d.Vendor.Email : "",
vendorId = d.VendorId
}) })
.ToListAsync(); .ToListAsync();
return Ok(data); return Ok(data);
} }
[HttpGet("GetDispatch/{id}")]
public async Task<IActionResult> GetDispatchById(int id)
{
var d = await _db.Dispatches
.Include(x => x.Vendor)
.Include(x => x.WorkOrder)
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.DispatchNumber,
x.PONumber,
x.NTEAmount,
x.Description,
x.Status,
x.ScheduledDate,
x.CompletedDate,
x.DispatchedAt,
x.EmailSent,
x.WorkOrderId,
woNumber = x.WorkOrder != null ? x.WorkOrder.InternalWONumber : "",
woTitle = x.WorkOrder != null ? x.WorkOrder.WorkerOrderTitle : "",
vendorName = x.Vendor != null ? x.Vendor.CompanyName : "",
vendorEmail = x.Vendor != null ? x.Vendor.Email : "",
vendorId = x.VendorId,
comments = _db.Comments
.Where(c => c.DispatchId == x.Id)
.OrderBy(c => c.CreatedDate)
.Select(c => new
{
c.Id,
c.CreatedDate,
c.Commenttext,
c.Commenter,
c.CommentType,
userName = c.ApplicationUser != null ? c.ApplicationUser.FirstName + " " + c.ApplicationUser.LastName : ""
}).ToList()
}).FirstOrDefaultAsync();
if (d == null)
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(d);
}
[HttpPost("UpdateDispatch")]
public async Task<IActionResult> UpdateDispatch([FromBody] UpdateDispatch_DTO model)
{
try
{
var dispatch = await _db.Dispatches.FindAsync(model.Id);
if (dispatch == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (dispatch.Status != model.Status)
{
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = dispatch.Status ?? "",
NewValue = model.Status ?? "",
Action = "dispatch_status_change",
CreatedAt = DateTime.UtcNow
});
}
dispatch.Status = model.Status;
dispatch.NTEAmount = model.NTEAmount;
dispatch.ScheduledDate = model.ScheduledDate;
dispatch.CompletedDate = model.CompletedDate;
dispatch.Description = model.Description;
if (model.Status == "Completed" && dispatch.CompletedDate == null)
dispatch.CompletedDate = DateTime.UtcNow;
await _db.SaveChangesAsync();
return Ok(new { message = "Dispatch updated" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost("AddDispatchComment")]
public async Task<IActionResult> AddDispatchComment([FromBody] DispatchComment_DTO model)
{
try
{
var dispatch = await _db.Dispatches.Include(d => d.Vendor).Include(d => d.WorkOrder)
.FirstOrDefaultAsync(d => d.Id == model.DispatchId);
if (dispatch == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "";
var comment = new Comments
{
CreatedDate = DateTime.UtcNow,
UserId = userId,
WorkerOrderId = dispatch.WorkOrderId,
DispatchId = dispatch.Id,
Commenttext = model.Text,
CommentType = "vendor",
};
_db.Comments.Add(comment);
await _db.SaveChangesAsync();
var user = await _db.Users.FindAsync(userId);
var senderName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "";
if (model.SendEmail && dispatch.Vendor?.Email != null && dispatch.WorkOrder != null)
{
var subject = $"[WO-{dispatch.WorkOrder.InternalWONumber}-{dispatch.DispatchNumber}] {dispatch.WorkOrder.WorkerOrderTitle}";
var html = $"<p>{model.Text}</p><br/><p style='color:#666;font-size:13px;'>{senderName}<br/>Sea Haven Industries</p><hr/><p style='color:#999;font-size:12px;'>Reply to this email to respond.</p>";
await _sendMessage.SendDispatchEmail(dispatch.Vendor.Email, subject, html, dispatch.ReplyToAddress);
}
return Ok(new
{
comment.Id,
comment.CreatedDate,
comment.Commenttext,
comment.CommentType,
userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""
});
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
private static string? ExtractZip(string? address) private static string? ExtractZip(string? address)
{ {
if (string.IsNullOrWhiteSpace(address)) return null; if (string.IsNullOrWhiteSpace(address)) return null;

View file

@ -0,0 +1,19 @@
namespace Api.SeaHavenIndustries.DTOs
{
public class UpdateDispatch_DTO
{
public int Id { get; set; }
public string? Status { get; set; }
public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? CompletedDate { get; set; }
public string? Description { get; set; }
}
public class DispatchComment_DTO
{
public int DispatchId { get; set; }
public string? Text { get; set; }
public bool SendEmail { get; set; } = true;
}
}

View file

@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddDispatchNumberAndCommentDispatchId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CompletedDate",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DispatchNumber",
table: "Dispatches",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DispatchId",
table: "Comments",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Comments_DispatchId",
table: "Comments",
column: "DispatchId");
migrationBuilder.AddForeignKey(
name: "FK_Comments_Dispatches_DispatchId",
table: "Comments",
column: "DispatchId",
principalTable: "Dispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Comments_Dispatches_DispatchId",
table: "Comments");
migrationBuilder.DropIndex(
name: "IX_Comments_DispatchId",
table: "Comments");
migrationBuilder.DropColumn(
name: "CompletedDate",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "DispatchNumber",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "DispatchId",
table: "Comments");
}
}
}

View file

@ -394,6 +394,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("DeletionTime") b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<int?>("DispatchId")
.HasColumnType("int");
b.Property<string>("Documents") b.Property<string>("Documents")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -423,6 +426,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("DispatchId");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.HasIndex("WorkerOrderId"); b.HasIndex("WorkerOrderId");
@ -635,6 +640,9 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id")); SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
b.Property<DateTime?>("CreatedDate") b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@ -647,6 +655,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Description") b.Property<string>("Description")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("DispatchNumber")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DispatchedAt") b.Property<DateTime?>("DispatchedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@ -2021,6 +2032,11 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
.WithMany("Comments")
.HasForeignKey("DispatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser")
.WithMany() .WithMany()
.HasForeignKey("UserId") .HasForeignKey("UserId")
@ -2033,6 +2049,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
b.Navigation("Dispatch");
b.Navigation("WorkOrder"); b.Navigation("WorkOrder");
}); });
@ -2422,6 +2440,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Employees"); b.Navigation("Employees");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
{
b.Navigation("Comments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
{ {
b.Navigation("Address"); b.Navigation("Address");

View file

@ -18,5 +18,8 @@ namespace Data.SeaHavenIndustries
public int? WorkerOrderId { get; set; } public int? WorkerOrderId { get; set; }
[ForeignKey(nameof(WorkerOrderId))] [ForeignKey(nameof(WorkerOrderId))]
public virtual WorkOrder? WorkOrder { get; set; } public virtual WorkOrder? WorkOrder { get; set; }
public int? DispatchId { get; set; }
[ForeignKey(nameof(DispatchId))]
public virtual Dispatch? Dispatch { get; set; }
} }
} }

View file

@ -12,13 +12,16 @@ namespace Data.SeaHavenIndustries
[ForeignKey(nameof(VendorId))] [ForeignKey(nameof(VendorId))]
public virtual Vendor? Vendor { get; set; } public virtual Vendor? Vendor { get; set; }
public string? DispatchNumber { get; set; }
public string? PONumber { get; set; } public string? PONumber { get; set; }
public decimal? NTEAmount { get; set; } public decimal? NTEAmount { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
public DateTime? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; } public DateTime? DispatchedAt { get; set; }
public bool EmailSent { get; set; } public bool EmailSent { get; set; }
public string? ReplyToAddress { get; set; } public string? ReplyToAddress { get; set; }
public ICollection<Comments>? Comments { get; set; }
} }
} }