Require auth on all work order QR codes and add top view QR

- StatusBump (GET + POST) now requires authentication; routes by job ID
  instead of anonymous ShopAccessCode GUID; records actual user name in
  status history instead of anonymous token string
- WorkOrder action generates a second "View Job" QR in the header linking
  to the authenticated Details page (for verifying specs and seeing catalog
  images on mobile); status bump QR updated to ID-based URL
- WorkOrder view: top QR added to header alongside job number; status bump
  label updated (removed "no login required" copy)
- StatusBump view: updated form routing from asp-route-token to asp-route-id
- HelpKnowledgeBase and Jobs help article updated with two-tier QR docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 13:27:43 -04:00
parent 9361cd4495
commit 4f976b1332
6 changed files with 209 additions and 50 deletions
@@ -573,53 +573,40 @@ public class JobsController : Controller
/// <summary>
/// Shows the status-bump selection page for a shop-floor QR code scan.
/// This endpoint is AllowAnonymous — it is accessed by workers scanning a printed QR code
/// on a work order, not by logged-in users. The <paramref name="token"/> is the job's
/// ShopAccessCode GUID. IgnoreQueryFilters is used so the scan works even if the worker's
/// device has no active session (common on shared shop tablets).
/// Requires authentication — workers must be logged in before scanning. Tenant isolation
/// is enforced by the normal global query filter on <c>GetByIdAsync</c>.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> StatusBump(Guid token)
public async Task<IActionResult> StatusBump(int id)
{
// Find job by ShopAccessCode — ignore tenant/soft-delete filters so the
// anonymous scan always finds the job regardless of active user session.
var jobs = await _unitOfWork.Jobs.FindAsync(
j => j.ShopAccessCode == token, true,
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
j => j.JobStatus,
j => j.JobPriority,
j => j.Customer);
var job = jobs.FirstOrDefault();
if (job == null) return NotFound("Work order token not found.");
if (job == null) return NotFound();
// Load all status lookups to determine next step
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
.OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses;
ViewBag.Job = job;
ViewBag.Token = token;
ViewBag.JobId = id;
return View();
}
/// <summary>
/// Processes a QR-code status bump from the shop floor — also AllowAnonymous.
/// Validates the token, applies the new status, records the change history, and broadcasts
/// a SignalR update so the office dashboard refreshes in real time.
/// The "bumped by" user is recorded as the ShopAccessCode token string (anonymous actor).
/// Processes a QR-code status bump from the shop floor. Requires authentication.
/// Records the authenticated user's name in status history.
/// </summary>
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StatusBump(Guid token, int newStatusId)
public async Task<IActionResult> StatusBump(int id, int newStatusId)
{
var jobs = await _unitOfWork.Jobs.FindAsync(
j => j.ShopAccessCode == token, true,
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
j => j.JobStatus,
j => j.Customer);
var job = jobs.FirstOrDefault();
if (job == null) return NotFound("Work order token not found.");
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
@@ -630,28 +617,29 @@ public class JobsController : Controller
job.UpdatedAt = DateTime.UtcNow;
if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow;
var userName = User.Identity?.Name ?? "Shop Floor";
await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory
{
JobId = job.Id,
FromStatusId = oldStatusId,
ToStatusId = newStatusId,
ChangedDate = DateTime.UtcNow,
Notes = "Updated via shop floor QR scan",
Notes = $"Updated via shop floor QR scan by {userName}",
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
});
await _unitOfWork.CompleteAsync();
// Reload job status for redirect display
return RedirectToAction(nameof(StatusBump), new { token });
return RedirectToAction(nameof(StatusBump), new { id });
}
/// <summary>
/// Renders the printable work order view for a job.
/// Loads all job items with their coats and prep services so the printed sheet contains
/// the full powder specification, colors, and preparation instructions for shop workers.
/// The work order also includes the QR code for ShopAccessCode-based status bumps.
/// Generates two sets of QR codes: a top "view" code linking to the authenticated job
/// details page, and bottom action codes for status bumping and powder usage logging.
/// </summary>
public async Task<IActionResult> WorkOrder(int? id)
{
@@ -713,13 +701,19 @@ public class JobsController : Controller
ViewBag.Company = company;
}
// Generate QR code for shop floor status bumping
var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { token = job.ShopAccessCode }, Request.Scheme)!;
using var qrGenerator = new QRCoder.QRCodeGenerator();
// Top QR: view/verify the job on mobile (authenticated job details page)
var detailsUrl = Url.Action("Details", "Jobs", new { id = job.Id }, Request.Scheme)!;
using var viewQrData = qrGenerator.CreateQrCode(detailsUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
using var viewQrCode = new QRCoder.PngByteQRCode(viewQrData);
ViewBag.ViewQrCodeBase64 = Convert.ToBase64String(viewQrCode.GetGraphic(4));
// Bottom QR: status bump (authenticated, job ID routed)
var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { id = job.Id }, Request.Scheme)!;
using var qrData = qrGenerator.CreateQrCode(statusBumpUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
using var qrCode = new QRCoder.PngByteQRCode(qrData);
var qrBytes = qrCode.GetGraphic(4);
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrBytes);
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrCode.GetGraphic(4));
ViewBag.StatusBumpUrl = statusBumpUrl;
// Generate QR codes for each unique inventory powder item so workers can