03 — Business Processes
End-to-end walkthroughs of every workflow in the system. Each section follows the same shape:
- What it is — one-paragraph summary in plain English.
- Who's involved — the roles that drive each step.
- State diagram — the formal lifecycle.
- Step-by-step — each step with the actor, action, system effect, and notifications.
- Cross-actor sequence — when more than one person hands off.
- Edge cases & variations — branches the happy path doesn't show.
- Notifications emitted — full list of events, recipients, and merge fields.
Index
- Asset lifecycle
- Audit process
- Asset transfer process
- Check-out / check-in process
- Maintenance process
- Notification flow (cross-cutting)
Asset lifecycle
What it is
The asset's full journey through the system, from the moment it's registered to the moment it's disposed. Most steps don't require a workflow gate — they happen as side effects of other processes (a transfer's Complete writes the new location, an audit's Approve writes a corrected location, a check-out flips the status to OnLoan).
Who's involved
- Super Admin registers new assets (via UI, by import, or via API).
- Field users (Auditors, Transfer participants, Custodians) drive the asset's history without ever clicking "Edit".
- Reviewers confirm corrections by approving audit lines.
State diagram
stateDiagram-v2 [*] --> Active : Asset created (default status) Active --> Reserved : status change Reserved --> Active : status change Active --> OnLoan : Check-out created OnLoan --> Active : Check-in Active --> InMaintenance : Work Order Started InMaintenance --> Active : Work Order Completed Active --> Missing : Audit "NotFound" approved Missing --> Active : Found in next audit + approved Active --> OutOfService : status change OutOfService --> Active : status change OutOfService --> Disposed : Final state Active --> Disposed : Final state Disposed --> [*] : Terminal — no further changes
Step-by-step
| Step | Actor | Action | System effect | Notifications |
|---|---|---|---|---|
| 1. Register | Super Admin | Adds asset via /assets (manual or import) |
New row in Assets with Status = Active. Code is auto-generated as {6-digit-seq}{classificationCode}. |
None |
| 2. Operational use | Anyone | Asset moves through transfers, audits, check-outs | Each step writes to the matching history table (AssetStatusHistory, AssetLocationHistory, AssetOrganizationHistory, AssetCustodyHistory) with Source reflecting the trigger |
Per-process |
| 3. Out of service | Super Admin | Sets Status = OutOfService |
Status history row written. Asset is filtered out of "active inventory" reports. | None |
| 4. Disposal | Super Admin | Sets Status = Disposed |
Terminal — AssetStatus.IsTerminal = true for Disposed. The system warns admins when transitioning out of a terminal status (it's allowed, but flagged in history as Manual source). |
None |
History tables — every change leaves a trail
flowchart LR A[Asset record] --> AS[AssetStatusHistory<br/>OldStatus → NewStatus<br/>+ Source + SourceRefId] A --> AL[AssetLocationHistory<br/>OldLocation → NewLocation] A --> AO[AssetOrganizationHistory<br/>OldOrganization → NewOrganization] A --> AC[AssetCustodyHistory<br/>OldCustodian → NewCustodian]
When viewing /assets/:id → History tab, the user sees a single unified timeline merging all four tables, sorted newest first. Each row shows:
- When it happened
- Who triggered it (user that caused the change)
- What kind of change (status / location / org / custody)
- The before → after values
- The source —
Manual(someone clicked Edit),Audit,Transfer,CheckOut,CheckIn,Maintenance,BulkImport, orSystem - The source reference id — clickable to open the originating audit, transfer, work order, etc.
Edge cases
- Re-classifying an asset is not supported — the asset's
Codeis partly derived from its classification. To "re-classify", delete the asset (soft delete) and re-import under the correct classification. This is intentional and called out in theAuditOutcomeenum docstring. - Reactivating a disposed asset is technically allowed (an admin can change the status back to Active), but every such change is recorded in the audit log and shows up as a
Manualsource change in the asset's status history. - Soft delete — when an admin deletes an asset, the row stays but is hidden everywhere by the global query filter. Reports don't see it. The code remains reserved (a freed code is never reissued).
Audit process
What it is
A planned physical inventory check. An Audit Planner designs the scope (which assets to audit), assigns it to an auditor (desktop or mobile), the auditor records what they found in the field, a reviewer approves or rejects each line, and approved corrections write back to the asset records.
Who's involved
| Role | Responsibility |
|---|---|
| Audit Planner | Designs the audit (name, priority, scope rules). Assigns to an auditor. Cancels/re-assigns if needed. |
| Auditor (desktop) | Walks the assigned asset list, marks each line, submits. |
| Mobile Auditor | Same job, mobile shell. Camera scanning + draft persistence. |
| Audit Reviewer | Walks the submitted lines, approves / rejects / modifies. Their decisions are what update the asset records. |
State diagram (Plan)
stateDiagram-v2 [*] --> Draft : Plan created Draft --> Scheduled : First assignment created Scheduled --> InProgress : First result submitted InProgress --> Completed : Last assignment fully reviewed Draft --> Cancelled Scheduled --> Cancelled InProgress --> Cancelled Completed --> [*]
State diagram (Assignment)
stateDiagram-v2 [*] --> Assigned : Audit Planner assigns Assigned --> Downloaded : Mobile downloads template Downloaded --> InProgress : First scan / save draft InProgress --> Submitted : Auditor clicks Submit Submitted --> InReview : Reviewer opens it InReview --> Completed : All lines reviewed Assigned --> Cancelled Downloaded --> Cancelled InProgress --> Cancelled
Step-by-step
sequenceDiagram
participant P as Audit Planner
participant SYS as System
participant A as Auditor / Mobile Auditor
participant R as Audit Reviewer
participant ASSET as Asset record
P->>SYS: 1. Create Plan with scope rules
SYS->>SYS: Resolve asset count (preview)
P->>SYS: 2. Create Assignment for plan + assign user
SYS->>SYS: Snapshot expected state per asset
SYS->>A: Notification "audit.assigned"
A->>SYS: 3. Open assignment, scan/type each asset
SYS->>SYS: Auto-save draft every 1s of inactivity
A->>SYS: 4. Submit (with idempotency key)
SYS->>SYS: Result + lines persisted; assignment → Submitted
SYS->>R: Notification "audit.result.review-required"
R->>SYS: 5. Open result, review each line
R->>SYS: 6. Approve / Reject / Modify line
alt All lines decided
SYS->>ASSET: Write back observed values<br/>(status, location, org)
SYS->>SYS: Assignment → Completed; Plan rolls up
SYS->>A: Notification "audit.result.approved"
end
Detailed action table
| # | Actor | Action | System effect |
|---|---|---|---|
| 1 | Audit Planner | Fills name, priority, scope | New AuditPlan (Status=Draft). Each AuditPlanScope row stores ScopeType (Location / Organization / Classification / All / AssetList / Combined). |
| 1a | Audit Planner | Clicks Preview scope | System returns the resolved asset count + sample IDs without persisting — useful sanity check before save. |
| 2 | Audit Planner | Saves the plan | Plan persisted. ResolvedAssetCount cached on the plan. |
| 3 | Audit Planner | Creates Assignment, picks auditor | New AuditAssignment (Status=Assigned). For each in-scope asset, an AuditAssignmentAsset snapshots the expected location, organization, classification, status, and custodian. One active assignment per plan — the system rejects a second one with a 409. |
| 4 | System | Sends notification | audit.assigned to the auditor. Merge fields: plan.name, plan.code, assignment.code, assignment.assetCount, link. |
| 5 | Auditor / Mobile | Opens the assignment | Status changes to Downloaded on first GET. |
| 6 | Auditor / Mobile | Scans / types each asset, marks outcome | Mobile: each scan auto-flips an expected row from "pending" to found (or location-mismatch if not in the active hierarchical filter); unrecognized scans become extra rows. Desktop: user selects each row's outcome from a dropdown. Both record IdentificationMethod = Qr or Manual. |
| 6a | Auditor / Mobile | Continues across breaks | Draft auto-saves to AuditAssignment.DraftPayload every 1 s of inactivity. Auditor can close the app and resume later. |
| 6b | Auditor / Mobile | Attaches photo to a row | Photo uploads → creates a Document → returns id → frontend pins to row. On submit, the system creates a DocumentLink to the result line. |
| 7 | Auditor / Mobile | Clicks Submit | System validates clientSubmissionId for idempotency. Creates AuditResult + AuditResultLine for each row + DocumentLink for each photo. Assignment → Submitted. Plan → InProgress (if first submission). DraftPayload cleared. |
| 8 | System | Sends notification | audit.result.review-required to every user holding audit-result.review (excluding the submitter). |
| 9 | Reviewer | Opens result, walks the lines | Status flips to InReview on first GET. |
| 10 | Reviewer | Per line: Approve / Reject / Modify | New AuditReviewAction row written. Approve: write the auditor's observed values to the asset (creates corresponding history rows with Source = Audit). Reject: ignore — asset stays as it was. Modify: write the reviewer's corrected values instead (still source=Audit). |
| 10a | Reviewer | Bulk approve | Picks N "Found" lines, single API call approves them all. |
| 11 | System | When all lines decided | Assignment → Completed. Plan → Completed if all assignments are done. |
| 12 | System | Sends notification | audit.result.approved to the original submitter when all lines are approved. |
Audit outcomes — what each one means
| Outcome | Auditor's claim | Reviewer's Approve action |
|---|---|---|
Found |
Asset is exactly where expected | No write-back (the asset record was already correct) |
NotFound |
Couldn't locate the asset | Sets Asset.StatusId = Missing |
LocationMismatch |
Found, but at a different location | Writes ObservedLocationId to the asset's LocationId + history row |
OrganizationMismatch |
Found, but registered to a different org | Writes ObservedOrganizationId to the asset's OrganizationId + history row |
Extra |
Scanned but wasn't in the assignment | Writes ObservedLocationId to the asset's LocationId (asset moves to where it was found) + history row |
Classification mismatch is intentionally not an outcome — see §Asset lifecycle edge cases above.
Cross-actor sequence — mobile audit with photo
sequenceDiagram
autonumber
participant M as Mobile Auditor
participant API as System
participant FS as File storage
M->>API: GET /mobile/assignment/:id
API->>M: Expected assets + location chain
M->>API: GET /draft (resume previous session)
API->>M: Saved scan state JSON
loop Walk the floor
M->>M: Scan QR (camera)
M->>M: Beep + vibrate (per outcome)
M->>API: Auto-save draft (debounced 1s)
API->>API: Persist DraftPayload
end
M->>API: POST photo (multipart)
API->>FS: Store file
API->>M: documentId
Note over M: Pin documentId on the row
M->>API: POST /audit-results (with clientSubmissionId)
API->>API: Validate idempotency
API->>API: Create result + lines + photo links
API->>API: Clear DraftPayload
API->>M: Result summary
Edge cases & variations
- No connectivity — Mobile shell shows "draft saved" with a stale timestamp. Auditor keeps scanning; the next save retries automatically.
- Excel offline path — Mobile screen offers "Download template / Fill offline / Upload back". The Excel file pre-fills with expected assets; auditor edits cells; system parses and submits via the same idempotent pipeline.
- Reviewer wants to reject the entire submission — there's no single button. The reviewer rejects each line, after which the assignment still flips to Completed (the act of deciding completes it; rejection means "no write-back"). The auditor can be re-assigned by the planner for a redo.
- Auditor disagrees with a rejection — out-of-band conversation; nothing in the system enforces a re-submission flow. The reviewer can change their decision (a new
AuditReviewActionrow is added; latest wins). - Plan with multiple assignments — multiple auditors covering disjoint sub-scopes. Plan rolls up to Completed only when all assignments are Completed. (The system enforces only-one-active-assignment-per-plan, but multiple cancelled + one active + plus more later is fine.)
Notifications emitted
| Template key | Trigger | Recipients | Merge fields |
|---|---|---|---|
audit.assigned |
Planner creates an assignment | The assigned auditor | plan.name, plan.code, assignment.code, assignment.assetCount, link, user.* |
audit.result.review-required |
Auditor submits results | Every user with audit-result.review (excluding submitter) |
result.code, assignment.code, plan.name, plan.code, submitter.name, link, user.* |
audit.result.approved |
Reviewer approves the last unreviewed line | The original submitter | result.code, link, user.* |
Asset transfer process
What it is
A formal request to move one or more assets from one organization or location to another. Goes through approval before the assets are actually marked "in transit", then a separate "receive" step at the destination confirms arrival, then "complete" writes the new location/organization back to each asset.
Who's involved
- Transfer Requester initiates the move (Draft → Submit).
- Transfer Approver decides go/no-go (Approve → InTransit, or Reject).
- Transfer Receiver marks lines received and completes the transfer (writes asset records).
State diagram
stateDiagram-v2 [*] --> Draft : Created Draft --> Submitted : Submit Submitted --> Approved : Approve Approved --> InTransit : (auto, same handler) InTransit --> Completed : Complete (after all lines received) Submitted --> Rejected : Reject (with reason) Draft --> Cancelled : Cancel Submitted --> Cancelled : Cancel Completed --> [*] Rejected --> [*] Cancelled --> [*]
Approved → InTransit is automatic — the Approve handler bumps both states in one save. Users see the transfer flip directly from Submitted to InTransit; the Approved state is barely visible in the UI.
Step-by-step
sequenceDiagram
autonumber
participant REQ as Transfer Requester
participant APP as Transfer Approver
participant REC as Transfer Receiver
participant SYS as System
participant ASSETS as Asset records
REQ->>SYS: Create transfer (Draft) with from/to + reason + lines
REQ->>SYS: Submit → status = Submitted
SYS->>APP: Notify "transfer.pending"
alt Approver approves
APP->>SYS: Approve → status = InTransit
SYS->>REQ: Notify "transfer.approved"
SYS->>REC: Notify "transfer.approved"
loop Lines arrive at destination
REC->>SYS: ReceiveLine (Ok / Damaged / Missing + notes)
end
REC->>SYS: Complete → status = Completed
SYS->>ASSETS: Write new location + organization<br/>Write history rows (Source=Transfer)
SYS->>REQ: Notify "transfer.completed"
else Approver rejects
APP->>SYS: Reject (with reason)
SYS->>REQ: Notify "transfer.rejected"
else Requester cancels
REQ->>SYS: Cancel
end
Detailed action table
| # | Actor | Action | System effect |
|---|---|---|---|
| 1 | Requester | Creates a transfer (Draft) | New AssetTransfer with Status=Draft. From/To organization + location captured. Reason is rich-text (Quill), supports inline images. |
| 1a | Requester | Adds asset lines | One AssetTransferLine per asset. Same asset can't appear twice in the same transfer (DB unique index). |
| 2 | Requester | Submits | Status → Submitted. |
| 3 | System | Sends notification | transfer.pending to every user with asset-transfer.approve (excluding requester). Merge: transfer.code, transfer.reason. |
| 4 | Approver | Reviews and approves | Status → Approved → InTransit (same handler). ApprovedBy, ApprovedAt stamped. |
| 4a | Approver | Or rejects with reason | Status → Rejected. RejectionReason captured. ApprovedBy still stamped (records who decided). |
| 5 | System | Sends notification | transfer.approved to requester + every user with asset-transfer.receive. Merge: transfer.code, transfer.reason, approver.name. |
| 6 | Receiver | Receives a single line | AssetTransferLine.ReceivedAt and ReceivedStatus (Ok / Damaged / Missing) set. |
| 6a | Receiver | OR receives all lines | All lines flipped in one call. |
| 7 | Receiver | Completes the transfer | Refused if any line has no ReceivedAt. For each line where ReceivedStatus = Ok, writes the asset's new LocationId + OrganizationId and creates AssetLocationHistory + AssetOrganizationHistory rows with Source = Transfer, SourceRefId = transferId. Status → Completed. CompletedBy, CompletedAt stamped. |
| 8 | System | Sends notification | transfer.completed to requester. |
Receive line statuses
| Status | What it means | Effect on Complete |
|---|---|---|
Ok |
Asset arrived, intact | New location + org written to the asset on Complete |
Damaged |
Arrived, but with damage | Same — asset moves, but the line's Notes documents the damage. Operationally, the receiver typically opens a maintenance request afterwards. |
Missing |
Did not arrive | Asset record is not updated for that line. The asset's location stays at "old". A reasonable next step is an audit. |
Edge cases & variations
- Approver wants edits — the system has no "request changes" state. Rejecting with a reason is the closest path; the requester re-creates a corrected transfer.
- Partial receive — Receiver can mark some lines now, others tomorrow. Complete is gated on every line having a
ReceivedAt. The transfer stays in InTransit until then. - Cancel after approval — not directly supported. The path is to Complete with all lines as
Missing, then file a corrective transfer to put them back. (asset-transfer.cancelonly applies to Draft / Submitted.) - Re-submit after rejection — no explicit re-open. The requester creates a new transfer (the rejected one stays as a permanent record).
Notifications emitted
| Template key | Trigger | Recipients | Merge fields |
|---|---|---|---|
transfer.pending |
Requester submits | Users with asset-transfer.approve (excluding requester) |
transfer.code, transfer.reason, link, user.* |
transfer.approved |
Approver approves | Requester + users with asset-transfer.receive |
transfer.code, transfer.reason, approver.name, link, user.* |
transfer.rejected |
Approver rejects | Requester | transfer.code, transfer.reason, transfer.rejectionReason, link, user.* |
transfer.completed |
Receiver completes | Requester | transfer.code, transfer.reason, link, user.* |
Check-out / check-in process
What it is
Issuing an asset to a person ("check-out") and getting it back ("check-in"). Independent from transfers — a check-out doesn't change the asset's home location, just records who currently holds it.
Who's involved
- Checkout Issuer creates check-outs.
- Checkout Returner accepts returns.
- Asset Custodian (the recipient) holds the asset and reads their own list.
State diagram
stateDiagram-v2 [*] --> Active : Issuer creates check-out Active --> Returned : Returner checks in Active --> Overdue : ExpectedReturnAt < now (computed) Active --> Cancelled : Issuer cancels Active --> Lost : Issuer marks lost Returned --> [*] Cancelled --> [*] Lost --> [*]
Overdueis computed, not persisted. The check-out list filters or flags rows whereStatus = ActiveandExpectedReturnAt < now. There's no scheduled job that rewrites the status.
Step-by-step
| # | Actor | Action | System effect |
|---|---|---|---|
| 1 | Issuer | Selects asset + custodian + purpose + expected return | DB unique index prevents two active check-outs of the same asset (UX (AssetId) WHERE Status = 'Active'). |
| 2 | System | Persists check-out | CheckOut row with Status=Active. Saves Asset.PreviousStatusId, then sets Asset.StatusId = OnLoan, Asset.CurrentCustodianId = CustodianId. Writes status + custody history rows with Source = CheckOut. |
| 3 | Custodian | Holds the asset | The /checkouts page shows this row in their "Active" tab. Their dashboard (if granted that read) can show "you have N items checked out, X overdue". |
| 4 | Returner | Marks return | Captures ReturnConditionRating (1-5) and ReturnNotes. CheckedInBy and CheckedInAt stamped. Status → Returned. |
| 5 | System | Reverts asset | Asset.StatusId = PreviousStatusId (whatever it was before the loan), Asset.CurrentCustodianId = null. Writes history rows with Source = CheckIn. |
Edge cases & variations
- Already overdue when returned — the system doesn't penalize; the check-out becomes Returned. Reports show that days-overdue history.
- Lost asset — the issuer can mark Status = Lost (manual transition; not automatic). Asset's status typically gets manually set to
Missingafter. - Cancel an active check-out — Issuer cancels; status reverts (same restore logic as a check-in but without condition + notes).
Notifications emitted
None by default. Adding one (e.g., "your asset is overdue") would mean adding a new template key + a scheduled job to publish it. Not in scope today.
Maintenance process
What it is
Three-layer model: Plans (recurring schedules), Requests (user-filed reports), Work Orders (the actual job).
Who's involved
Reminder: Maintenance has no seeded workflow role. The default install puts every maintenance permission on Super Admin only. See 02 §Maintenance for proposed custom roles.
| Role (custom) | Responsibility |
|---|---|
| Maintenance Planner | Creates recurring plans. Generates work orders from a plan. |
| Maintenance Reviewer | Triages incoming requests; promotes them to work orders. |
| Technician (User assignee) | Executes assigned work orders. |
| Vendor (Vendor assignee) | External contractor — captured via AssignedToVendorId. |
| Reporter (any custodian) | Files a request when something breaks. |
State diagrams
Maintenance Plan (recurring)
stateDiagram-v2 [*] --> Active Active --> Active : Generate work orders<br/>(NextDueDate must be ≤ now) Active --> Inactive : Disable Inactive --> Active : Re-enable
Maintenance Request
stateDiagram-v2 [*] --> Open : Custodian files Open --> UnderReview : Reviewer opens it UnderReview --> PromotedToWorkOrder : Reviewer promotes UnderReview --> Rejected : Reviewer rejects (with reason) Open --> Cancelled : Reporter cancels (if granted) PromotedToWorkOrder --> [*] Rejected --> [*] Cancelled --> [*]
Work Order
stateDiagram-v2 [*] --> Open : Created without assignee [*] --> Assigned : Created with vendor (DefaultVendor on plan) Open --> Assigned : Assign user/vendor Assigned --> InProgress : Start InProgress --> Completed : Complete (with cost + notes) InProgress --> OnHold : Pause OnHold --> InProgress : Resume Open --> Cancelled Assigned --> Cancelled OnHold --> Cancelled Completed --> [*] Cancelled --> [*]
Plan-driven flow
sequenceDiagram
autonumber
participant MP as Maintenance Planner
participant SYS as System
participant T as Technician
participant ASSET as Asset records
MP->>SYS: Create Plan (multi-asset, frequency, default vendor)
Note over SYS: NextDueDate = today
loop Every cycle
MP->>SYS: Click "Generate work orders"
Note over SYS: Refused if NextDueDate > now
SYS->>SYS: Create one WorkOrder per plan-asset<br/>(Origin=Plan, OriginRefId=planId)
SYS->>SYS: LastPerformedDate=now<br/>NextDueDate += frequency
SYS->>T: Notification "work-order.assigned"<br/>(if assigned)
T->>SYS: Start WO → asset Status=InMaintenance
T->>SYS: Complete WO with cost + notes
SYS->>ASSET: Restore asset status to PreviousStatusId
end
Request-driven flow
sequenceDiagram
autonumber
participant U as Reporter
participant R as Maintenance Reviewer
participant T as Technician
participant SYS as System
U->>SYS: Create Request (asset, severity, summary, description)
Note over SYS: Status = Open
R->>SYS: Open & investigate
Note over SYS: Status = UnderReview
alt Promote to Work Order
R->>SYS: Promote (with WO type, priority, schedule, assignee)
SYS->>SYS: Create WorkOrder; link request<br/>Status = PromotedToWorkOrder
SYS->>T: Notification "work-order.assigned"
else Reject
R->>SYS: Reject (with reason)
end
Detailed action tables
Plan handling
| # | Actor | Action | System effect |
|---|---|---|---|
| 1 | Planner | Creates a plan with ≥1 assets | MaintenancePlan + a MaintenancePlanAsset per asset. Frequency = Daily/Weekly/Monthly/Quarterly/Yearly/CustomDays. NextDueDate set. DefaultVendorId optional. |
| 2 | Planner | Generates work orders | Refused if NextDueDate > now (one cycle, one batch). Creates one WorkOrder per plan-asset with Origin = Plan, OriginRefId = plan.Id. WOs default to Status = Open, or Assigned if DefaultVendorId is set. |
| 3 | System | Rolls plan forward | LastPerformedDate = now. NextDueDate = AdvanceDueDate(now, Frequency, CustomIntervalDays). |
Request handling
| # | Actor | Action | System effect |
|---|---|---|---|
| 1 | Reporter | Creates request | MaintenanceRequest (Status=Open, Severity, Summary, Description, ReportedAt, ReportedBy). |
| 2 | Reviewer | Promotes to WO | New WorkOrder (Origin=Request, OriginRefId=request.Id). Request.LinkedWorkOrderId set, Status = PromotedToWorkOrder. |
| 2a | Reviewer | OR rejects | Status = Rejected. RejectionReason captured. |
Work order execution
| # | Actor | Action | System effect |
|---|---|---|---|
| 1 | Technician | Starts WO | Status: Assigned → InProgress. Captures Asset.PreviousStatusId, sets Asset.StatusId = InMaintenance. Status history written. ActualStart timestamp. |
| 2 | Technician | Completes WO | Captures: ActualEnd, DowntimeHours, LaborCost, PartsCost, CurrencyCode, ResolutionNotes. Computes TotalCost = LaborCost + PartsCost. Restores Asset.StatusId = PreviousStatusId. Status: InProgress → Completed. History written. |
| 2a | Technician | OR cancels | Any non-terminal status → Cancelled. Asset status restored if WO had been started. |
Edge cases & variations
- Plan generates while a previous batch is still open — the system allows it. Frequency-based plans don't gate on prior completion. If a quarterly plan generates, technicians are halfway done with the previous batch, and another quarter rolls around — both batches are open at once. (Add a custom check if you don't want this.)
- Multi-asset plan with one missing asset — the generate command emits work orders for every plan-asset row that still resolves. If a plan-asset row points at a soft-deleted asset, that work order is skipped silently.
- Cost rollup — the system stores
LaborCostandPartsCostseparately, plus a denormalizedTotalCostset at complete time. Currency is per-WO, not normalized.
Notifications emitted
| Template key | Trigger | Recipients | Merge fields |
|---|---|---|---|
work-order.assigned |
Work order assigned to a user (via Create-with-assignee, Assign action, or Plan/Request promotion when assignee is preset) | The assignee user | wo.code, wo.summary, link, user.* |
There's no notification on Maintenance Request submission or rejection out of the box. Add custom templates if you want them.
Notification flow (cross-cutting)
What it is
How an event becomes an in-app notification + an optional email.
Who's involved
| Role / actor | Responsibility |
|---|---|
| Domain handler (Submit, Approve, …) | Calls INotificationService.PublishAsync(templateKey, recipients, mergeFields). |
| Notification service | Looks up template, renders bilingual subject + body, writes Notification rows (in-app) and queues NotificationDelivery rows (email). |
| Notification delivery worker | Background service. Polls the queue every 15 s (configurable). Sends emails via SMTP. Retries with backoff. |
| Recipient (any user) | Sees the bell badge update; reads in-app inbox; receives the email. Manages personal preferences per template + channel. |
| Super Admin | Edits templates at /settings/notification-templates (Quill rich-text editor). |
Template structure
Each template has up to two rows:
| Channel | Required? | Content |
|---|---|---|
| InApp | Required | Subject + body (plain text after HTML stripping). Drives the bell + inbox. |
| Optional | Subject + HTML body. Sent via SMTP. |
Both subjects and bodies are bilingual: *Primary (configurable language) + *Secondary. The recipient's PreferredLanguage decides which one renders.
Merge fields
Every template can reference {{ field.path }} placeholders. Fields the system auto-injects for every notification:
| Field | Value |
|---|---|
user.fullName |
Recipient's bilingual full name (primary picked per their lang preference) |
user.email |
Recipient's email |
user.code |
Recipient's user code (e.g. USR-000007) |
user.userName |
Recipient's username |
link |
Relative URL to the originating record (e.g., /transfers/c5f1...) |
The publishing handler can also push entity-specific fields (e.g., transfer.code, transfer.reason, approver.name) — see each process section above for the full list per template key.
Delivery flow
sequenceDiagram
participant H as Domain handler
participant NS as Notification service
participant DB as Database
participant W as Delivery worker
participant SMTP as SMTP server
participant U as Recipient
H->>NS: PublishAsync(templateKey, recipients, mergeFields)
NS->>DB: Look up template by key
NS->>NS: Render subject + body (per recipient language)
loop Each recipient
NS->>DB: Insert Notification (in-app, plain text)
alt Email channel enabled & recipient has email
NS->>DB: Insert NotificationDelivery (Status=Queued)
end
end
Note over W: Polls every 15s
W->>DB: Select NotificationDelivery WHERE Status=Queued AND NextAttemptAt<=now
loop Each delivery
W->>SMTP: Send
alt Success
W->>DB: Status=Sent, ProviderMessageId
else Failure
W->>DB: Attempts++, NextAttemptAt = backoff
Note over W: After MaxAttempts → Failed
end
end
U->>U: Sees bell badge increment (next 60s poll)
U->>U: Reads inbox or opens email
Recipient targeting
Three publish modes:
| Method | Targets | Used by |
|---|---|---|
PublishAsync(templateKey, userId, ...) |
One user | audit.result.approved to the submitter |
PublishToManyAsync(templateKey, userIds, ...) |
A specific list | (rare) |
PublishToPermissionAsync(templateKey, permissionKey, ...) |
Every user holding permissionKey (optionally excluding one user) |
audit.result.review-required (everyone with audit-result.review); transfer.pending (everyone with asset-transfer.approve) |
User preferences
Every recipient can opt out of a specific template on a specific channel. At /preferences, they see a grid: each row is a template key from the NotificationEventCatalog, columns for InApp and Email.
The default (no preference row) is enabled — opt-out, not opt-in.
Edge cases & variations
- Recipient has no email — Email delivery is skipped (logged, no error).
- Email channel disabled at the template level (
IsActive = false) — no email queued. - SMTP master switch (
EmailProviderSettings.Enabled = false) — worker idles, queue accumulates. Re-enabling drains. - Staging redirect (
RedirectAllEnabled = true) — every email reroutes toRedirectAllToAddress, original recipient prefixed in the subject. Useful when most user accounts in staging have fake emails. - Quill-in-template gotcha — the admin template editor wraps text in
<p>...</p>and inserts . The renderer normalizes both to plain space before substituting merge fields, otherwise placeholders like{{ transfer.code }}would never match. For InApp output, HTML is stripped after substitution; Email keeps the HTML.
Where to go next
| To learn… | See |
|---|---|
| What each screen looks like | 04 — Features by Module |
| Reports analyzing all of these processes | 05 — Reporting & Insights |
| Bilingual rendering of notifications | 06 — Localization & RTL |