03 — Business Processes

End-to-end walkthroughs of every workflow in the system. Each section follows the same shape:

  1. What it is — one-paragraph summary in plain English.
  2. Who's involved — the roles that drive each step.
  3. State diagram — the formal lifecycle.
  4. Step-by-step — each step with the actor, action, system effect, and notifications.
  5. Cross-actor sequence — when more than one person hands off.
  6. Edge cases & variations — branches the happy path doesn't show.
  7. Notifications emitted — full list of events, recipients, and merge fields.

Index

  1. Asset lifecycle
  2. Audit process
  3. Asset transfer process
  4. Check-out / check-in process
  5. Maintenance process
  6. 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 TerminalAssetStatus.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/:idHistory 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 sourceManual (someone clicked Edit), Audit, Transfer, CheckOut, CheckIn, Maintenance, BulkImport, or System
  • 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 Code is 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 the AuditOutcome enum 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 Manual source 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 AuditReviewAction row 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.cancel only 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 --> [*]

Overdue is computed, not persisted. The check-out list filters or flags rows where Status = Active and ExpectedReturnAt < 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 Missing after.
  • 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 LaborCost and PartsCost separately, plus a denormalized TotalCost set 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.
Email 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 to RedirectAllToAddress, 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 &nbsp;. The renderer normalizes both to plain space before substituting merge fields, otherwise placeholders like {{&nbsp;transfer.code&nbsp;}} 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