heflo-api
v1.0.36
Published
HEFLO BPM API for TypeScript
Readme
HEFLO API
API (Application Programming Interface) to handle customizations on HEFLO BPM using Node.js and TypeScript.
Use it to react to process events (field changes, sequence flows, button clicks, record lists, file uploads), read and write form fields, query the export database with SQL, create records, send emails, and show messages to the end user.
Installing
Using npm:
$ npm install heflo-apiConcepts
Every customization is an HTTP endpoint that HEFLO calls when an event happens. The pattern is always the same:
- Build an event context from the incoming request. The context class you pick depends on the event (
OnChanged,OnExecuteSequenceFlow,OnTrigger, ...). - Read data with
Get(...)and the context accessors (WorkItem,Token,Record,Person, ...). - Change data with
Set(...), or run side effects (queries, records, emails). - Return
context.GetModifiedData()so HEFLO applies the changes to the instance.
import * as HEFLOApi from 'heflo-api'
exports.onChanged = async (req: any, resp: any) => {
try {
// 1. Build the event context from the request
const context = new HEFLOApi.Events.WorkItem.OnChanged(req)
// 2. Read a field
const total = context.WorkItem.Get('total')
// 3. Change another field
context.WorkItem.Set('totalWithTax', total * 1.1)
// 4. Return the delta so HEFLO persists it
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing the request, details: ' + err)
}
}Setting up an Express server
A minimal server that wires event handlers to routes.
// server.ts
const express = require('express')
const dotenv = require('dotenv')
dotenv.config()
const app = express()
app.use(express.json())
const workItemEvents = require('./events/workItem')
app.post('/on-changed', workItemEvents.onChanged)
app.post('/on-execute', workItemEvents.onExecute)
app.post('/on-resource-calculation', workItemEvents.onResourceCalculation)
app.listen(process.env.PORT || 3000, () => {
console.log(`Server running on port ${process.env.PORT || 3000}`)
})Environment variables (.env) used by the examples below:
PORT=3000
DOMAIN=your-environment-identifier
CLIENT_ID=your-api-key
CLIENT_SECRET=your-secret-keyEvent contexts
The Events namespace groups every context by the entity it belongs to.
| Namespace | Use it for |
| --- | --- |
| Events.WorkItem | Business process instances (forms, tasks, transitions). |
| Events.Custom | Custom entities. |
| Events.Person | People records. |
| Events.Department | Department records. |
Available events per namespace: OnChanged, OnTrigger, OnInitRecordList, OnAddedRecord, OnRecordChanged, OnRemovedRecord. Events.WorkItem additionally exposes OnExecuteSequenceFlow, OnResourceCalculation, OnAddedFile, OnRemovedFile and BeforeInit.
Examples
Reacting to a field change (OnChanged)
Fired whenever the user changes a form field. Read the changed value, compute a derived value, and mirror it back.
exports.onChanged = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnChanged(req)
const quantity = context.WorkItem.Get('quantity')
const unitPrice = context.WorkItem.Get('unitPrice')
context.WorkItem.Set('lineTotal', (quantity || 0) * (unitPrice || 0))
// Pass { refresh: true } to force the front-end to reload the form fields
resp.json(context.GetModifiedData({ refresh: true }))
} catch (err) {
resp.status(500).send('Error executing OnChanged, details: ' + err)
}
}Validating on a button click (OnTrigger)
OnTrigger runs when the user clicks a form button. Use it to validate input and give feedback with ShowWarning / ShowError / ShowInformation / ShowSuccess.
exports.validatePeriod = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnTrigger(req)
const startDate = context.WorkItem.Get('startDate')
const totalDays = context.WorkItem.Get('totalDays')
if (!startDate) {
context.ShowWarning('Please select a start date before continuing.')
} else if (totalDays <= 0) {
context.ShowWarning('The total number of days must be greater than zero.')
} else {
context.ShowSuccess('Period validated successfully.')
}
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing validatePeriod, details: ' + err)
}
}Acting on a sequence flow (OnExecuteSequenceFlow)
Fired when the instance transitions to the next task. Source and Target describe the flow elements involved.
exports.onExecute = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnExecuteSequenceFlow(req)
const sourceLabel = context.Source ? context.Source.Label : undefined
const targetLabel = context.Target ? context.Target.Label : undefined
context.WorkItem.Set('previousTask', sourceLabel)
context.WorkItem.Set('nextTask', targetLabel)
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing OnExecuteSequenceFlow, details: ' + err)
}
}Calculating who performs a task (OnResourceCalculation)
Return a flat list of recipients. Each item is either an email address or an array holding a department/group name.
exports.onResourceCalculation = async (req: any, resp: any) => {
try {
new HEFLOApi.Events.WorkItem.OnResourceCalculation(req)
resp.json([
'[email protected]',
'[email protected]',
['finance-department']
])
} catch (err) {
resp.status(500).send('Error executing OnResourceCalculation, details: ' + err)
}
}Deciding whether an instance should be created (BeforeInit)
BeforeInit gives access to the initialization Payload before the instance exists.
exports.beforeInit = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.BeforeInit(req)
resp.json({ payload: context.Payload })
} catch (err) {
resp.status(500).send('Error executing BeforeInit, details: ' + err)
}
}Working with record lists
Record lists are the grids inside a form. Read the changed row through context.Record, and manage the whole list through context.WorkItem.
Reading and writing the current row
OnInitRecordList, OnAddedRecord and OnRecordChanged all expose the affected row via context.Record.
exports.onAddedRecord = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnAddedRecord(req)
const amount = context.Record.Get('amount')
context.Record.Set('amountFormatted', amount.toFixed(2))
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing OnAddedRecord, details: ' + err)
}
}Reading, adding and removing rows
exports.rebuildList = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnTrigger(req)
// Read every row currently in the list
const items = await context.WorkItem.GetListAsync('orderItems')
if (Array.isArray(items) && items.length > 0) {
// Clear the list
const oids = items.map((item: any) => item.oid)
await context.WorkItem.DeleteRecordsAsync('orderItems', oids)
}
// Add a fresh row
await context.WorkItem.AddRecordAsync('orderItems', {
product: 'Sample product',
quantity: 1,
amount: 100
})
resp.json(context.GetModifiedData({ refresh: true }))
} catch (err) {
resp.status(500).send('Error executing rebuildList, details: ' + err)
}
}Handling files
exports.onAddedFile = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnAddedFile(req)
context.WorkItem.Set('lastUploadedFileName', context.File.Filename)
context.WorkItem.Set('lastUploadedFileUrl', context.File.Url)
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing OnAddedFile, details: ' + err)
}
}Events on other entities
The same programming model applies to custom entities, people and departments.
// Custom entity: use context.Entity to reach the record's fields
exports.customOnChanged = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.Custom.OnChanged(req)
context.Entity.Set('nameUpper', (context.Entity.Get('name') || '').toUpperCase())
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing Custom.OnChanged, details: ' + err)
}
}
// Person: context.Person exposes typed fields such as Email
exports.personOnChanged = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.Person.OnChanged(req)
context.Person.Set('emailMirror', context.Person.Email)
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing Person.OnChanged, details: ' + err)
}
}
// Department: context.Department exposes typed fields such as Code
exports.departmentOnChanged = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.Department.OnChanged(req)
context.Department.Set('codeMirror', context.Department.Code)
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing Department.OnChanged, details: ' + err)
}
}Creating and saving records
Create a new record with NewAsync, populate it, then persist it with SaveAsync. When the record is not derived from the current form there is no delta to return.
exports.createPerson = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnExecuteSequenceFlow(req)
const person = await HEFLOApi.Person.NewAsync(context)
person.Name = context.WorkItem.Get('applicantName') // typed field
person.Email = context.WorkItem.Get('applicantEmail') // typed field
person.Set('customField', context.WorkItem.Get('extraInfo')) // custom field
await person.SaveAsync(context)
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing createPerson, details: ' + err)
}
}Looking up existing records
exports.loadRequester = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnExecuteSequenceFlow(req)
// The requester of the current instance
const requester = await context.WorkItem.GetRequesterAsync(context)
if (requester && requester.DepartmentOid) {
const department = await HEFLOApi.Department.FindAsync(context, requester.DepartmentOid)
const manager = await department.GetManagerAsync(context)
context.WorkItem.Set('managerOid', manager ? manager.Oid : 0)
}
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing loadRequester, details: ' + err)
}
}Querying the export database (SQL)
Run a read-only SELECT against the environment export database. Page size must be 100 or less. Always parameterize user-provided values.
With an event context
When you already have a context, use its QueryAsync — it reuses the authenticated session.
exports.listRecentInstances = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnExecuteSequenceFlow(req)
const page = req.body['page'] || 1
const itemsPerPage = req.body['itemsPerPage'] || 10
const sql = 'select oid, number from vWorkItem order by oid desc'
const rows = await context.QueryAsync(sql, page, itemsPerPage, [])
resp.json(rows)
} catch (err) {
resp.status(500).send('Error executing listRecentInstances, details: ' + err)
}
}Without a context (standalone), using parameters
The static Context.QueryAsync authenticates on demand from environment/API/secret keys. Prefer named parameters over string interpolation to avoid SQL injection.
exports.searchPeople = async (req: any, resp: any) => {
try {
const searchTerm = req.body['search'] || ''
const page = req.body['page'] || 1
const itemsPerPage = req.body['itemsPerPage'] || 10
const sql = 'select oid, name from vPerson where name like @name'
const parameters = [{ name: '@name', value: `%${searchTerm}%` }]
const people = await HEFLOApi.Context.QueryAsync(
String(process.env.DOMAIN),
String(process.env.CLIENT_ID),
String(process.env.CLIENT_SECRET),
sql,
page,
itemsPerPage,
parameters
)
resp.json(people.map((item: any) => ({ id: item.oid, name: item.name })))
} catch (err) {
resp.status(500).send('Error executing searchPeople, details: ' + err)
}
}Process tables (_wi and _tk)
Besides the generic views (vWorkItem, vToken, vPerson, ...), every published process gets two dedicated export tables named after it:
| Table | Grain | Holds |
| --- | --- | --- |
| vp<ProcessName>_wi | One row per instance (work item) | The whole request, regardless of how many tokens it has. Look here for request-level data such as Requestor / RequestorEmail / RequestorOid, plus every form field of the process. |
| vp<ProcessName>_tk | One row per token (task execution) | Per-thread data. A token is like a thread of the process: usually there is only one, but constructs such as parallel gateways split the flow into several tokens, each with its own Responsible / ResponsibleEmail. |
RequestorvsResponsible. Use_wi.Requestorwhen you want who opened the request (one per instance). Use_tk.Responsiblewhen you want who owns a specific task/thread — with parallel branches there can be several distinct responsibles for the same instance.
Custom fields appear both by their alias (e.g. title) and by their technical name cp_<hash>. If a query fails on the alias, the field may only be exposed under its technical name.
Query the _wi table when you want instance-level data (one row per request). Since we have an event context here, use context.QueryAsync — no domain/API/secret keys needed:
exports.listInstances = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnExecuteSequenceFlow(req)
const search = req.body['search'] || ''
const page = req.body['page'] || 1
const itemsPerPage = req.body['itemsPerPage'] || 10
// vp<ProcessName>_wi — one row per instance (the whole request)
// Requestor is the person who opened the request.
const sql = `
select wi.Oid, wi.Number, wi.title, wi.Requestor
from vpMyProcess_wi wi
where wi.title like @search
and wi.Number is not null
order by wi.Number desc`
const rows = await context.QueryAsync(sql, page, itemsPerPage, [
{ name: '@search', value: `%${search}%` }
])
resp.json(rows.map((item: any) => ({ id: item.Oid, name: `${item.Number} ${item.title}` })))
} catch (err) {
resp.status(500).send('Error executing listInstances, details: ' + err)
}
}Query the _tk table when you need per-token data — e.g. who is responsible for each open thread. With parallel gateways a single instance can return several rows here, one per token:
exports.openTokens = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnExecuteSequenceFlow(req)
// vp<ProcessName>_tk — one row per token (thread).
// Responsible is the user who owns that specific token.
const sql = `
select tk.Oid as tokenOid, tk.Number as instance,
tk.Responsible, tk.ResponsibleEmail
from vpMyProcess_tk tk
where tk.Number is not null and tk.Number <> '0'
order by tk.Number desc`
const rows = await context.QueryAsync(sql, 1, 100, [])
resp.json(rows)
} catch (err) {
resp.status(500).send('Error executing executedTasks, details: ' + err)
}
}You can join both tables (and the generic views) to combine instance data with per-task execution data:
const sql = `
select wi.Number as instance,
wi.title,
wi.Requestor, -- who opened the request (instance level)
tk.Oid as tokenOid,
tk.Responsible, -- who owns this token/thread
te.FlowElementText as task,
te.StartDate as taskStart
from vpMyProcess_wi wi
inner join vpMyProcess_tk tk on tk.Number = wi.Number
inner join vTokenExecution te on te.TokenOid = tk.Oid
where te.FlowElementText is not null
order by wi.Number desc, te.StartDate asc`A reusable helper that returns a single row or a list:
async function queryDatabase(sql: string, page = 1, itemsPerPage = 10, singleRow = true) {
const rows = await HEFLOApi.Context.QueryAsync(
String(process.env.DOMAIN),
String(process.env.CLIENT_ID),
String(process.env.CLIENT_SECRET),
sql,
page,
itemsPerPage,
[]
)
if (!rows) return singleRow ? null : []
return singleRow ? rows[0] ?? null : rows
}Sending email
exports.notifyApprover = async (req: any, resp: any) => {
try {
const context = new HEFLOApi.Events.WorkItem.OnExecuteSequenceFlow(req)
await context.SendMailAsync(
['[email protected]'],
'Approval required',
'<p>A new request is waiting for your approval.</p>',
[], // attachment URLs
context.Token.Oid // link the email to the instance conversation tab
)
resp.json(context.GetModifiedData())
} catch (err) {
resp.status(500).send('Error executing notifyApprover, details: ' + err)
}
}Showing messages to the user
Available on any event context. Messages are returned to the front-end together with the delta.
context.ShowInformation('Data loaded from the integration.')
context.ShowWarning('The selected date falls on a holiday.')
context.ShowError('The requester could not be identified.')
context.ShowSuccess('Request submitted successfully.')
context.ShowDialog('<b>Attention:</b> this action cannot be undone.')Calling an external HEFLO web service
Open a new instance (or trigger a web service) from outside a process event.
import axios from 'axios'
exports.openWorkItem = async (req: any, resp: any) => {
try {
const url = req.body['webserviceUrl'] || process.env.OPEN_WORKITEM_URL
const headers: any = { 'Content-Type': 'application/json' }
if (process.env.BASIC_AUTH_USER && process.env.BASIC_AUTH_PASS) {
const encoded = Buffer
.from(`${process.env.BASIC_AUTH_USER}:${process.env.BASIC_AUTH_PASS}`)
.toString('base64')
headers.Authorization = `Basic ${encoded}`
}
const response = await axios.post(url, { name: req.body['name'] }, { headers, timeout: 30000 })
resp.json({ success: true, status: response.status, data: response.data })
} catch (err) {
resp.status(500).send('Error opening work item, details: ' + err)
}
}Deploying to AWS Lambda (Serverless Framework)
The library is HTTP-transport agnostic, so the same handlers run on AWS Lambda. Lambda delivers the request body as a string, so parse it and wrap it in a body object before building the context.
import * as HEFLOApi from 'heflo-api'
exports.onChanged = async (event: any) => {
try {
const body = JSON.parse(event.body)
const context = new HEFLOApi.Events.WorkItem.OnChanged({ body })
context.WorkItem.Set('processed', true)
return {
statusCode: 200,
body: JSON.stringify(context.GetModifiedData({ refresh: true }))
}
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({ status: 500, message: `Something went wrong: ${error}` })
}
}
}# serverless.yml
service: my-heflo-customizations
frameworkVersion: '3'
provider:
name: aws
runtime: nodejs18.x
timeout: 30
functions:
onChanged:
handler: events/workItem.onChanged
events:
- http:
path: /on-changed
method: postAPI reference
Full generated documentation is available under docs/.
