Notifications & Workflow (NTF)
Module code NTF · Release R0 (outbox, email), R1 (approval engine, push, SMS, templates), R2 (WhatsApp, public webhooks) · Packaging Core (WhatsApp is metered) · Requirements
FR-NTF-*,BR-NTF-*in the PRD and BRD · Related request-approval workflow, API endpoints
1. Overview and scope
NTF provides two shared services:
- The workflow (approval) engine. Approval chains are data. Every request type in every module (attendance, leave, comp-off, claims, separation, timesheets, payroll run, device approval, profile change) starts a workflow instance; approvers act on tasks in one inbox.
- Notifications. Domain events become messages through templates and channels (in-app, email, push, SMS, WhatsApp), with user preferences, quiet hours, digesting and delivery tracking. The transactional outbox guarantees nothing is lost. Webhooks deliver events to client systems (R2).
Out of scope: the business meaning of a request (owned by the requesting module), marketing email.
2. Learned from the reference product
Source: Spryple analysis, §3.3, §17, §18 and §20.
- [Observed] One approval pattern everywhere: Submitted → Approved or Rejected, rejection needs a reason, the requester can edit or delete while Submitted and cancel approved future-dated items, with history tabs (SP-X-03).
- [Observed] Requests made on behalf of an employee by a manager are auto-approved (SP-X-04).
- [Observed] Email notifications exist for onboarding, documents, induction, password reset and timesheet defaulters; leave emails are configurable; a Messages Master lets admins edit every on-screen message (SP-X-02, SP-X-09).
- [Observed] Claims use a two-stage chain (manager verifies, finance approves) and exit goes straight to HR, skipping the line manager (§8, §9).
- [Inferred] Multi-level approvals, delegation and escalation are not documented (§20, question 2).
3. Our design decisions
| ID | Decision [Proposed] | Reason |
|---|---|---|
| D-NTF-01 | Workflow definitions are versioned data: steps with approver resolvers, conditions, mode (any or all), SLA and escalation. Instances pin the version at start. | One engine for all modules; a definition change never alters in-flight requests. |
| D-NTF-02 | The engine calls back the owning module through a registered handler (onApproved, onRejected, onCancelled); the module emits its own domain event (e.g. lve.request.approved). | Business rules and events stay in the module. |
| D-NTF-03 | Delegation is first-class: a user delegates all or selected request types for a date range. Tasks resolve to the delegate; both are shown. | Approvals stall when managers are on leave. |
| D-NTF-04 | Escalation: reminder at remind_after_hours, escalate at escalate_after_hours to the resolver's next level or a role; optional auto-action after a final timeout (off by default). | Bounded turnaround without silent auto-approval. |
| D-NTF-05 | On-behalf: when an actor with create_on_behalf raises a request, steps the actor could approve are auto-approved and recorded as such; later steps still run. Mode FULL gives reference-product parity. | Speed for managers, control for HR-gated steps. |
| D-NTF-06 | The requester is never an approver; if a resolver returns the requester, the step is skipped to the next resolver. | Segregation of duties. |
| D-NTF-07 | Notification text uses i18n bundles plus tenant overrides of selected templates, not an editable "Messages Master" for every screen string. | Localisation and safe templates (sandboxed). |
| D-NTF-08 | Transactional outbox in the same DB transaction as the state change; a relay publishes to the in-process bus and BullMQ. | No lost emails or webhooks. |
4. Domain model
Resolvers: REPORTING_MANAGER, SKIP_LEVEL_MANAGER, MANAGER_AT_LEVEL (n), DEPARTMENT_HEAD, ROLE_IN_SCOPE (role key and scope relative to the requester, e.g. hr_manager at the requester's LOCATION), SPECIFIC_USER, PROJECT_MANAGER (TSM), COUNTERPART (shift swap). Conditions use the same safe expression language as policies, over the instance context (e.g. days > 3, amount > 5000, type == "EL").
5. State machines
5.1 Workflow instance
5.2 Task
6. Key business logic
6.1 Starting an instance and resolving steps
start(request_type, subject, requester, initiator, context):
v = active workflow version for request_type on today
inst = new instance(v, status Running, current_step = 0)
advance(inst)
advance(inst):
for step in v.steps after inst.current_step, ordered:
if step.condition and not evaluate(step.condition, inst.context): continue
approvers = resolve(step.resolver, step.resolver_args, inst.requester)
approvers = approvers minus inst.requester # never self-approve
approvers = approvers.map(applyDelegation) # active delegations
if approvers is empty: approvers = fallback(step) # e.g. hr_manager at LOCATION
if onBehalfCovers(inst, step, approvers):
record auto action APPROVE by initiator ; continue
create tasks (one per approver) with due_at = now + step.escalate_after_hours
inst.current_step = step.step_no ; notify approvers ; return
inst.status = Approved ; call module handler onApproved(subject)
6.2 On-behalf auto-approval
onBehalfCovers(inst, step, approvers):
if inst.initiator == inst.requester: return false
if v.on_behalf_mode == FULL: return true # reference parity
if v.on_behalf_mode == UP_TO_ACTOR_LEVEL:
return inst.initiator in approvers # actor could approve this step
return false # NONE
Example: leave of 5 days, chain = reporting manager, then HR when days > 3. The manager applies on behalf: step 1 auto-approves, step 2 goes to HR. With 2 days, the request is approved at once.
6.3 Acting on a task
act(task, actor, action, comment):
require actor == task.assignee or actor holds an active delegation from the assignee
require comment when action == REJECT or SEND_BACK
record ntf_action ; task.status = Done
if action == REJECT: inst.status = Rejected ; handler onRejected
if action == SEND_BACK: inst.status = SentBack ; handler onSentBack
if action == APPROVE:
if step.mode == ANY: cancel sibling tasks ; advance(inst)
if step.mode == ALL and all sibling tasks Done: advance(inst)
# Bulk approve calls act() per task in one request with per-item results.
6.4 Escalation sweep
every 5 minutes per cell:
for task in Open or Reminded or Escalated where due checks are passed:
if now > created_at + remind_after_hours and not reminded: remind assignee
if now > due_at:
target = escalate_to (NEXT_MANAGER, ROLE, or SPECIFIC_USER)
add a task for target (original stays open) ; escalation_level += 1
due_at = now + step.escalate_after_hours
emit ntf.task.escalated
if timeout_action set and escalation_level > max_levels: auto action and record auto = true
6.5 Notification fan-out
on outbox event E:
rules = subscriptions of E.event_type to recipient resolvers (requester, approvers, HR)
for recipient in resolve(rules):
channels = preference(recipient, E) or template defaults
if quiet hours and not urgent: defer push and SMS ; email may digest
render template (locale of recipient, tenant override if present) in a sandbox
enqueue per channel with an idempotency key (event id + recipient + channel)
failed deliveries retry with backoff; final failure falls back to the in-app inbox
7. Configuration
| Setting | Type | Default | Description |
|---|---|---|---|
workflow.<request_type>.steps | array of object | one step: reporting manager | Chain per request type |
workflow.<request_type>.on_behalf_mode | enum | UP_TO_ACTOR_LEVEL | NONE, UP_TO_ACTOR_LEVEL, FULL |
step.remind_after_hours | integer | 24 | Reminder |
step.escalate_after_hours | integer | 48 | Escalation |
step.escalate_to | enum | NEXT_MANAGER | NEXT_MANAGER, ROLE, SPECIFIC_USER |
step.timeout_action | enum | NONE | NONE, APPROVE, REJECT |
step.mode | enum | ANY | ANY or ALL |
fallback_approver_role | string | hr_manager | When a resolver finds nobody |
delegation.max_days | integer | 60 | Longest delegation |
quiet_hours | time range | 21:00 to 08:00 | No push or SMS unless urgent |
digest.email | enum | OFF | OFF, DAILY for approvers |
channels.enabled | array of string | [IN_APP, EMAIL, PUSH] | SMS and WhatsApp need provider set-up |
webhooks.max_attempts | integer | 8 | Before DLQ |
Default chains shipped as templates: attendance request, regularization, permission, OT (reporting manager); shift swap (counterpart, then reporting manager); leave (reporting manager, plus HR when days exceed 5); comp-off (reporting manager); claim (reporting manager verifies, finance approves, plus department head above an amount); separation (reporting manager, then HR); timesheet (project manager); payroll run (finance admin); device (reporting manager); profile change (HR).
8. Screens by persona
| Persona | Web | Mobile |
|---|---|---|
| Any approver | Unified inbox (filters by type, team, age), task detail with request context and history, bulk approve or reject, delegate | Inbox, swipe approve or reject, push deep links |
| Requester | Request timeline (who has it, since when), withdraw, resubmit after send-back | Same |
| HR admin | Workflow designer (steps, resolvers, conditions, SLAs) with a "test with employee X" preview, template overrides, channel set-up | — |
| Any user | Notification centre, preferences, quiet hours | Notification centre |
| Org owner | Approval turnaround dashboard, stuck requests | — |
9. Events
| Event | Consumers |
|---|---|
ntf.instance.started | RPT |
ntf.task.created | NTF fan-out (approver notification), MOB badge |
ntf.task.reminded, ntf.task.escalated | NTF fan-out, RPT (SLA) |
ntf.instance.completed | Owning module handler (in process), RPT |
ntf.message.failed | Ops alerting when the failure rate is high |
ntf.webhook.failed | Tenant admin notification, DLQ |
Consumed: every *.submitted event that a module maps to a workflow start (through the module's call to start, not the event itself), and every domain event that has a notification template.
10. API surface
| Resource | Main operations |
|---|---|
/v1/ntf/tasks | GET my inbox, POST /:id/approve, POST /:id/reject, POST /:id/send-back, POST /bulk |
/v1/ntf/instances/:id | GET timeline |
/v1/ntf/workflows and /v1/ntf/workflows/:id/versions | CRUD, publish, POST /:id/preview |
/v1/ntf/delegations | CRUD |
/v1/ntf/notifications | GET inbox, POST /:id/read |
/v1/ntf/preferences | GET, PUT |
/v1/ntf/templates | GET, PUT tenant override |
/v1/ntf/webhooks | CRUD, POST /:id/test, deliveries (R2) |
Detailed in API endpoints.
11. Permissions
| Permission | Scopes | Default roles |
|---|---|---|
ntf.task.act | own tasks | any approver |
ntf.task.reassign | ORG | hr_admin |
ntf.instance.read | SELF, TEAM, ORG | requester, approvers, hr_manager |
ntf.workflow.manage | ORG | hr_admin |
ntf.delegation.manage | SELF, ORG | any user (SELF), hr_admin (ORG) |
ntf.template.manage | ORG | hr_admin |
ntf.webhook.manage | ORG | org_owner |
12. Reports
| Report | Contents |
|---|---|
| Approval turnaround | Median and p90 time to decision by request type, approver and team |
| Pending approvals ageing | Open tasks by age bucket, escalated tasks |
| Auto-approvals | On-behalf and timeout auto-actions, for audit |
| Delivery health | Messages by channel and status, bounce and failure rates |
| Webhook deliveries | Success, retries, DLQ |
13. Release and priority
| Capability | Release | MoSCoW |
|---|---|---|
| Outbox, email channel, in-app inbox | R0 | Must |
| Approval engine: up to 2 levels, conditions, on-behalf, send-back (R0/R1); N-level and any/all parallel steps (R2, Should) | R1 / R2 | Must / Should |
| Delegation and escalation with reminders | R1 | Must |
| Push (Expo) and SMS channels; preferences; quiet hours | R1 | Must |
| Workflow designer with preview | R1 | Should |
| Template overrides and i18n (English first) | R1 | Should |
| WhatsApp channel | R2 | Should |
| Public webhooks with HMAC signing | R2 | Should |
| Email digests | R2 | Could |
14. Open questions
- Should a rejected step allow the requester to "appeal" to the next level? The default is no; send-back covers most needs.
- Do we allow parallel branches (e.g. IT and Finance clearance at once) in the engine, or keep them in EMP tasks? The default keeps parallel work in tasks and approvals linear.
- Which SMS and WhatsApp providers meet DLT registration and template approval needs for Indian tenants? This affects R1 SMS timelines.