← All case studies

Case study · 06 / 11

A work-order workflow where the client chose the risk

A case study — GMP Work Order Request & QA Approval


Summary

| | |
|---|---|
| **Problem** | A GMP-regulated manufacturer needed maintenance work-order requests, QA sign-off and handover to move off email onto a traceable system. |
| **Constraint** | SPFx is client-side. None of the security the requirements asked for can be enforced in React — and the enforcement layer was deferred by agreement. |
| **Solution** | Five SPFx web parts over a 10-state status machine with dual routing lanes, built against a numbered scope with full requirement traceability. |
| **Outcome** | v2.5 packaged and deployed to a test site. Documented honestly as *"ready to deploy to a test site, not ready to be called production-ready."* |
| **Role** | Scope authoring, architecture, all five web parts and the service layer, provisioning, PDF generation, deployment and test documentation. |
| **Stack** | SPFx 1.21.1 · React 17 · TypeScript 5.3 · PnPjs v4 · Fluent UI v8 · jsPDF · SheetJS · Node 22 |

1. The problem

Maintenance work-order requests were running on email. In a GMP-regulated environment that is a traceability problem before it is an efficiency one: there is no reliable record of who requested what, who approved it, what changed between submission and approval, or why.

The requirement was a digital form with numbering, four request types, a QA approval gate, an append-only audit trail, role-based visibility, notifications, a printable A4 form for wet signature, and a dashboard — with CMMS integration explicitly out of scope for phase one.

2. The constraints

Constraint one: SPFx cannot enforce anything. The scope document states it in bold, because it determines what the whole solution can honestly claim:

Role-based UI trimming, the own-requests-only scope and post-handover locking cannot be enforced in React. The React layer hides actions and scopes its queries; the actual enforcement is SharePoint permissions plus a Power Automate flow running under a service account. Treat the UI as convenience, the permissions as control.

And then the harder sentence, added as the build progressed:

As at v2.5 neither of those controls exists yet. Both were deferred by agreement. Until then a user with Contribute rights can edit a handed-over request directly through the SharePoint list UI. That is an acceptable position for UAT. It is not one to describe as enforced.

Constraint two: some of the decisions were the client's, and were not the safe ones. Nineteen decisions (D1–D19) were confirmed in meetings and recorded. Several of them constrain the design in ways an engineer would not choose.

3. Architecture

One communication site, four pages, five web parts, six lists and a shared service layer.

Pages NewRequest · RequestDetail · Dashboard · Admin Web parts woRequestForm · woRequestDetail · woDashboard · woAdmin Data WORequests · WOApprovalHistory · WOAttachments (library) WODepartments · WOConfig · WOEmailTemplates Automation Flow 1 notifications · Flow 2 lockdown on handover Security WO Requesters · WO QA Approvers · WO Facilities · WO Admins

Ten services — request, attachment, history, security, config, request-number, email template, PDF, diagnostics — shared across all web parts rather than duplicated. Models are interface-first, with a WOStatusMachine of pure functions holding the transition and permission matrix, so all five web parts share one definition of what each role may do in each state.

Two lanes. Unscheduled requests pass through a QA gate. The other three types — Emergency, Work Request, Planned Outside Schedule — bypass QA entirely and go straight to Pending WO Initiation. Both lanes converge on WO Initiated → Handed Over → Closed, with the lock falling at handover.

4. The interesting decisions

4.1 D4 — the decision to document rather than argue

The client decided that on the three bypass request types, QA is involved in no way at all — not on submission, not later, not when the requester declares a GMP impact, not as a copy on any notification.

That is uncomfortable in a regulated setting, and the temptation is either to quietly add a notification "for safety" or to keep relitigating it. Neither is right. What was done instead:

  • The decision is recorded in the scope with its rationale and its date, marked as a confirmed client decision rather than an oversight.
  • The Power Automate documentation opens with a warning block stating it plainly, and adds that *the fix for disagreement is a change request, not a flow edit.*
  • A pre-go-live audit step was written into the doc: search the flow definition for qaRecipients, expect exactly five occurrences, treat anything else as a violation. Three statuses are reachable by both lanes, so each carries an explicit condition preventing the bypass branch from picking up QA.
  • The test scenarios mark the rows where QA must receive nothing as "none — QA not notified by design", and the document opens by saying that this is a *pass* condition, not a gap.
  • The agreed mitigation is visibility: GMP impact is still captured on every request, and the Diagnostics tab lists bypass-type requests that declare one.

One honest detail: GMP impact became free text partway through, so the diagnostic reads the text and treats anything that is not plainly a denial as a declared impact. It over-reports rather than under-reports — the correct direction for a safety check.

4.2 D19 — a failure that reports itself nowhere

Roles resolve from the SharePoint groups a user belongs to directly. Nest an AD security group inside one of the four role groups and that check cannot see it: everyone inside the nested group holds no role at all, while the group looks correctly populated to whoever set it up.

Nothing errors. Nothing logs. The administrator sees a full group; the users see an application with no permissions and no explanation.

The people picker therefore offers individuals only, and the Setup tool's Security tab flags any nested group added outside it. The cost — role membership maintained per person rather than inherited from an existing AD group — is recorded as a cost, not hidden.

4.3 A scalability failure caught before go-live

RequesterUser is the column carrying the own-requests-only visibility filter. It is read on every dashboard load, for every requester. It was not indexed.

At the expected volume of 4,000 requests a year, WORequests crosses SharePoint's 5,000-item list view threshold in roughly fifteen months — at which point the requester dashboard stops working. Not slowly: it fails outright, for everyone, permanently, and no amount of restarting fixes it.

RequesterUser, RequesterDepartment, Equipment and SharePoint's own Author are all indexed now. The related discipline: every dashboard query is server-side filtered and paged, and the KPI scan is capped at 5,000 rows with a warning when it caps — an unbounded dashboard would have started reporting silently wrong numbers in year two.

4.4 A lookup-drift bug in production

After the lists were deleted and recreated to clear mangled internal names, the dashboard failed on every load:

The field 'EquipmentRef' is not supported in query. The lookup list does not exist.

A SharePoint lookup column stores the target list's GUID, not its name. The recreated lists carried new GUIDs, so three lookups were still aimed at lists that no longer existed.

The fix reads each lookup column's SchemaXml, extracts the stored List="…" GUID and compares it against the live target list, reporting a mismatch as Drifted and recreating the column — necessary because a lookup's target cannot be edited, only replaced. (LookupList cannot be $selected across a fields collection: it lives on SP.FieldLookup rather than the SP.Field base type and returns 400. Hence reading SchemaXml on the single column instead.)

The generalised hazard, now written into the team's conventions: deleting a list silently orphans every lookup pointing at it, in every other list.

4.5 Provisioning that handles the cases check-then-create misses

Beyond creating what is missing, the setup tool does three things a naive provisioner does not:

  • Choice-option reconciliation — adding a new status option to an existing Choice column. Check-then-create sees the column exists and moves on; the new status silently never works.
  • Type-mismatch detection — a column whose type has changed.
  • Retired-column detection — a column the schema no longer declares.

The two destructive repairs (recreate a retyped column, delete a retired one) sit behind their own button with their own preview, deliberately outside the ordinary Provision / Repair action.

A post-build audit also found that provisioning was not idempotent: versioning and default views were written unconditionally on every run, and the view rewrite destroyed any column an administrator had added to a default view — silently, every time. Both now compare before writing.

4.6 Cutting the bundles 5×

Replacing a heavy control library with a Fluent-only people picker and dynamically importing the PDF and Excel libraries:

| Web part | Before | After |
|---|---|---|
| Admin | 4.2 MB | **780 KB** |
| Dashboard | 3.4 MB | **584 KB** |
| Request form | 2.5 MB | **528 KB** |
| Detail | 2.0 MB | **400 KB** |

The PDF chunk (428 KB) and Excel chunk (412 KB) download only for someone who actually generates a document or exports.

4.7 Generating the PDF rather than printing the page

The first implementation was a print-optimised web part plus browser Save-as-PDF. It has an unfixable problem: there is no supported API for hiding SharePoint's own chrome — suite bar, site header, navigation, command bar — from inside a web part. The print stylesheet has to target them by id, data-automation-id and class prefix, and Microsoft changes those without notice.

v2.0 replaced it with jsPDF drawing from the data, not from the rendered page: real selectable text, correct pagination, no dependency on Microsoft's DOM. It is filed automatically at handover, downloadable at any status, and generation is engineered so it can never fail a workflow transition — a document that cannot be produced must not block a handover. Deleting the print web part also consolidated the A4 layout into one place instead of two.

5. Outcome

v2.5 packaged and deployed to a test site. Clean production build, zero lint warnings, five bundles, full requirement traceability from scope through implementation to test scenario, plus a deployment guide and a test-scenario document written to be walked by a human.

A post-build audit found and fixed eight defects, two of them regressions introduced by earlier fixes — including the non-idempotent provisioning above and a read-only detection path that had become dead code, so a handed-over request would have shown its "this is read-only" message in a red *error* bar instead of an information bar, reintroducing exactly the raw-error experience the fix was meant to prevent.

The build report's own verdict:

Ready to deploy to a test site. Not ready to be called production-ready. The gap is not code, it is evidence: no line of this has executed against SharePoint.

6. What is still open, stated plainly

  • No end-to-end UAT. The four defects the audit found are exactly the class only a real tenant surfaces — the fact that they survived to that pass is itself evidence the untested paths carry risk.
  • No automated tests on the status machine, which grew a status and three transitions in the last release and is the single most-depended-on file in the solution.
  • The permission model and lockdown flow do not exist. Lock-after-handover, own-requests visibility, the audit trail's integrity and the filed PDF all depend on them. By agreement — but the solution should not be described as enforcing any of them today.
  • Retention cannot express "2 years from the Closed date" in Purview directly. Two options are documented; the choice was escalated to the client's records manager rather than made unilaterally.

7. What I'd take from this

Recording a decision is cheaper than re-arguing it. D4 would have been relitigated in every review, and eventually someone would have added a QA notification "to be safe" and broken a confirmed requirement. Writing it down with its date, its rationale, a test that asserts it and an audit step that verifies it settled the question permanently.

The dangerous failures are the ones that report nothing. A nested security group that produces no roles. An unindexed column that works fine for fifteen months. A dashboard silently capping its own KPI scan. None of these throw. All of them were found by asking "what does this look like when it is wrong?"

Know what your platform can enforce before you promise it. Half the security in the requirements was not achievable in the layer it was specified in. Saying so in the scope document — rather than discovering it at audit — is the difference between a design decision and an incident.