From 4f976b1332fe80d60e1b410b840b8cc410e29f32 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 25 Apr 2026 13:27:43 -0400 Subject: [PATCH] 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 --- CREATE_JENKINS_PROD_DEPLOY.md | 100 ++++++++++++++++++ .../Controllers/JobsController.cs | 58 +++++----- .../Helpers/HelpKnowledgeBase.cs | 10 ++ src/PowderCoating.Web/Views/Help/Jobs.cshtml | 44 ++++++++ .../Views/Jobs/StatusBump.cshtml | 8 +- .../Views/Jobs/WorkOrder.cshtml | 39 ++++--- 6 files changed, 209 insertions(+), 50 deletions(-) create mode 100644 CREATE_JENKINS_PROD_DEPLOY.md diff --git a/CREATE_JENKINS_PROD_DEPLOY.md b/CREATE_JENKINS_PROD_DEPLOY.md new file mode 100644 index 0000000..887378c --- /dev/null +++ b/CREATE_JENKINS_PROD_DEPLOY.md @@ -0,0 +1,100 @@ +# Jenkins Production Deployment Setup + +## What was created + +| File | Purpose | +|---|---| +| `Jenkinsfile` | Production pipeline — manual trigger only | +| `jenkins/Dockerfile` | Custom image: Jenkins LTS + .NET 8 + Azure CLI + sqlcmd + dotnet-ef | +| `.config/dotnet-tools.json` | Tool manifest pinning dotnet-ef 8.0.11 | + +--- + +## One-time setup steps + +### 1. Build and run your custom Jenkins image + +On your Ubuntu Docker host: +```bash +cd /path/to/repo +docker build -t pcl-jenkins ./jenkins +docker run -d -p 8080:8080 -p 50000:50000 \ + -v jenkins_home:/var/jenkins_home \ + --name pcl-jenkins pcl-jenkins +``` + +If you already have a Jenkins container running, rebuild the image and recreate the container (volume data is preserved). + +--- + +### 2. Create an Azure Service Principal + +Run this once from **your machine** (not Jenkins): +```bash +az login +az ad sp create-for-rbac \ + --name "pcl-jenkins-deploy" \ + --role contributor \ + --scopes /subscriptions//resourceGroups/ +``` + +Save the output — you need `appId`, `password`, `tenant`, and your subscription ID. + +--- + +### 3. Create a SQL Server deployment login + +In SSMS or Azure portal query editor, run on your Azure SQL server (as admin): +```sql +CREATE LOGIN pcl_deploy WITH PASSWORD = 'ChooseAStrongPassword123!'; +USE PowderCoatingDb; +CREATE USER pcl_deploy FOR LOGIN pcl_deploy; +ALTER ROLE db_owner ADD MEMBER pcl_deploy; -- needs DDL rights for migrations +``` + +> After migrations are stable you can demote this to `db_datareader`/`db_datawriter` + explicit DDL permissions, but `db_owner` is easiest to start. + +--- + +### 4. Add Jenkins credentials + +Go to **Jenkins → Manage Jenkins → Credentials → System → Global** and add 10 **Secret Text** credentials with these exact IDs: + +| Credential ID | Value | +|---|---| +| `PCL_AZURE_CLIENT_ID` | `appId` from step 2 | +| `PCL_AZURE_CLIENT_SECRET` | `password` from step 2 | +| `PCL_AZURE_TENANT_ID` | `tenant` from step 2 | +| `PCL_AZURE_SUBSCRIPTION_ID` | Your Azure subscription GUID | +| `PCL_AZURE_RESOURCE_GROUP` | e.g. `powder-coating-prod` | +| `PCL_AZURE_APP_NAME` | Your App Service name (e.g. `pcl-app`) | +| `PCL_SQL_SERVER` | e.g. `pcl-sql.database.windows.net` | +| `PCL_SQL_DATABASE` | e.g. `PowderCoatingDb` | +| `PCL_SQL_USER` | `pcl_deploy` | +| `PCL_SQL_PASSWORD` | The password you set in step 3 | + +--- + +### 5. Create the Jenkins Pipeline job + +1. **New Item → Pipeline** — name it "PCL Production Deploy" +2. Under **Pipeline**, set **Definition** = `Pipeline script from SCM` +3. SCM = Git, repo URL, branch `*/master`, Script Path = `Jenkinsfile` +4. **Do NOT** check any triggers (no poll SCM, no build periodically, no webhook) +5. Save + +To deploy: open the job → **Build Now**. That's your "Go!" button. + +--- + +## How each stage works + +| Stage | What happens | +|---|---| +| **Checkout** | Pulls `master`, logs the commit SHA | +| **Build & Test** | `dotnet restore` → `dotnet build -c Release` → `dotnet test` (results published to Jenkins) | +| **Publish** | `dotnet publish -c Release` → `./publish/` | +| **Generate Migration Script** | `dotnet ef migrations script --idempotent` — no DB connection needed. Script is **archived as a build artifact** so you can inspect it before or after | +| **Apply Migration** | `sqlcmd` runs the idempotent script against Azure SQL. `-b` flag makes it fail-fast on errors | +| **Deploy to Azure** | ZIP the publish folder, `az webapp deployment source config-zip` | +| **Smoke Test** | `curl` the App Service root URL — expects HTTP 200 or 302 | diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index c7f5989..52c0fce 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -573,53 +573,40 @@ public class JobsController : Controller /// /// 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 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 GetByIdAsync. /// - [AllowAnonymous] - public async Task StatusBump(Guid token) + public async Task 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(); } /// - /// 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. /// - [AllowAnonymous] [HttpPost] [ValidateAntiForgeryToken] - public async Task StatusBump(Guid token, int newStatusId) + public async Task 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 }); } /// /// 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. /// public async Task 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 diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index 65a37e2..4110765 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -296,6 +296,16 @@ public static class HelpKnowledgeBase **Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." + **Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in. + + *Top QR — View Job:* Located in the header next to the job number. Scanning it opens the full Job Details page on the worker's phone — shows all items, catalog images, powder specs, coatings, prep services, and special instructions. Use this to verify you're working the right job and to see catalog product images on mobile. + + *Bottom QR codes — Actions:* + - **Update Status** — advances the job to its next status stage. Opens a dedicated mobile-friendly status bump page where the worker confirms the new stage. The status change is recorded in history with the logged-in worker's name. + - **Log Powder Usage** — one QR per unique powder/inventory item on the job. Scanning opens the inventory usage log page pre-filled with that item and the job, so the worker can record actual lbs used without navigating through the app. + + All QR codes require login — workers must have an active account. Logging in once on their phone is sufficient for the session. + **Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record. - Access: Jobs list page → printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank. - The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line. diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml index 15c81af..4e547dd 100644 --- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml +++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml @@ -576,6 +576,49 @@ +
+

+ Work Order QR Codes +

+

+ Every printed job work order includes two tiers of QR codes — one for viewing + the job and a separate set for acting on it. This gives shop workers everything + they need from a printed sheet without touching the desktop app. + All QR codes require a logged-in account. +

+ +

Top QR — View Job

+

+ Located in the work order header, next to the job number. Scan it with your phone to open the + full Job Details page — items, catalog product images, powder specs, coatings, + prep services, and special instructions. Use it to verify you're working the right job or to + see catalog item images on your phone without hunting through the app. +

+ +

Bottom QR — Update Status

+

+ Scan to open a mobile-friendly status bump page for this job. Tap the button to advance to the + next stage (or put the job on hold). The status change is recorded in history with your name — + no anonymous bumps. +

+ +

Bottom QR — Log Powder Usage

+

+ One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled + with that powder and the job number, so you can record actual lbs used in seconds without + navigating through the app. +

+ + +
+

Blank Work Order

@@ -643,6 +686,7 @@ Part Intake Shop Mobile Changing the Customer + Work Order QR Codes Blank Work Order diff --git a/src/PowderCoating.Web/Views/Jobs/StatusBump.cshtml b/src/PowderCoating.Web/Views/Jobs/StatusBump.cshtml index 9354c47..2a6996d 100644 --- a/src/PowderCoating.Web/Views/Jobs/StatusBump.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/StatusBump.cshtml @@ -3,7 +3,7 @@ Layout = null; var job = ViewBag.Job as PowderCoating.Core.Entities.Job; var allStatuses = ViewBag.AllStatuses as List; - var token = (Guid)ViewBag.Token; + var jobId = (int)ViewBag.JobId; // Determine next/previous status options var currentOrder = job!.JobStatus.DisplayOrder; @@ -240,7 +240,7 @@ @* On hold — offer resume (next logical status after resume by advancing) *@ @if (nextStatus != null) { -

+ @Html.AntiForgeryToken()