Building Custom Actions
Learn how to build reusable action types for Stack9 automations. This guide covers action structure, parameter definition with runtypes, using the context (database, services, logger, connectors), error handling, workflow integration, testing, and real-world examples.
What You'll Build
In this guide, you'll build a complete set of Custom Actions for a subscription and customer management system:
- Action type structure with proper TypeScript types
- Parameter validation using runtypes
- Database operations with Knex and entity service
- External API integration via connectors
- Error handling and response formatting
- Workflow integration with state transitions
- Testing strategies for action types
- Real examples from production Stack9 applications
Time to complete: 60-90 minutes
Prerequisites
- Understanding of Action Types
- Basic TypeScript knowledge
- Familiarity with Automations
- Understanding of async/await
Understanding Action Types
Action types are reusable TypeScript classes that encapsulate business logic for automations. They:
- Accept typed parameters validated at runtime
- Access all Stack9 services (database, entity service, connectors, etc.)
- Return structured responses that can be used by subsequent actions
- Handle errors gracefully with proper logging
- Are testable in isolation
Step 1: Basic Action Type Structure
Let's start with a simple action type that creates a customer task.
File: src/action-types/createCustomerTask.ts
import {
S9AutomationActionType,
S9AutomationActionTypeDescription,
S9AutomationContext,
S9InputTypes,
} from '@april9au/stack9-sdk';
import * as rt from 'runtypes';
// Define parameter types
const Params = rt.Record({
customer_id: rt.Number,
task_summary: rt.String,
task_description: rt.String,
due_date: rt.String,
});
type Params = rt.Static<typeof Params>;
export class CreateCustomerTask implements S9AutomationActionType {
// Description shown in automation builder UI
description: S9AutomationActionTypeDescription = {
name: 'Create Customer Task',
key: 'create_customer_task',
description: 'Create a task assigned to a customer',
icon: 'CheckSquareOutlined',
properties: [
{
name: 'customer_id',
label: 'Customer ID',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }],
},
{
name: 'task_summary',
label: 'Task Summary',
type: S9InputTypes.TextField,
rules: [{ required: true }],
},
{
name: 'task_description',
label: 'Task Description',
type: S9InputTypes.TextArea,
rules: [{ required: true }],
},
{
name: 'due_date',
label: 'Due Date',
type: S9InputTypes.DatePicker,
rules: [{ required: true }],
},
],
};
// Execute method - contains the business logic
execute = async ({
next,
params,
services,
logger,
}: S9AutomationContext): Promise<void> => {
try {
// Validate parameters
const { customer_id, task_summary, task_description, due_date } =
Params.check(params);
logger.info('Creating customer task', {
customer_id,
task_summary,
});
// Use entity service to create task
await services.entity.insertTask('customer', customer_id, {
summary: task_summary,
description: task_description,
due_date: due_date,
is_completed: false,
});
// Return success response
return next({
response_code: 200,
message: 'Task created successfully',
});
} catch (error) {
logger.error('Failed to create customer task', { error, params });
return next({
response_code: 500,
message: 'Failed to create task',
error: error.message,
});
}
};
}
Key components:
description: Metadata for UIParams: Runtime type validationexecute: Async function with business logicnext: Callback to continue workflow
Step 2: Parameter Definition with Runtypes
Runtypes provide runtime type checking for parameters. Here are common patterns:
Basic Types
import * as rt from 'runtypes';
// Simple types
const Params = rt.Record({
customer_id: rt.Number,
email: rt.String,
is_active: rt.Boolean,
created_at: rt.String,
});
// Optional parameters
const Params = rt.Record({
customer_id: rt.Number,
notes: rt.String.optional(),
tags: rt.Array(rt.String).optional(),
});
// Nullable parameters
const Params = rt.Record({
customer_id: rt.Number,
phone: rt.String.nullable(),
address: rt.String.Or(rt.Null),
});
Complex Types
// Nested objects
const Params = rt.Record({
customer: rt.Record({
name: rt.String,
email: rt.String,
address: rt.Record({
street: rt.String,
city: rt.String,
postal_code: rt.String,
}),
}),
});
// Arrays
const Params = rt.Record({
customer_ids: rt.Array(rt.Number),
statuses: rt.Array(rt.String),
});
// Union types
const Params = rt.Record({
status: rt.Union(
rt.Literal('active'),
rt.Literal('inactive'),
rt.Literal('suspended')
),
});
// Constraints
const Params = rt.Record({
crn: rt.Number.withConstraint((n) => Number.isInteger(n) || 'Must be integer'),
email: rt.String.withConstraint((s) => s.includes('@') || 'Invalid email'),
age: rt.Number.withConstraint((n) => n >= 18 || 'Must be 18 or older'),
due_date: rt.String.withConstraint(
(s) => !isNaN(Date.parse(s)) || 'Invalid date'
),
});
Real-World Example: Subscription Change Request Validation
import * as rt from 'runtypes';
import dayjs from 'dayjs';
const SubscriptionChangeParams = rt.Record({
subscription_id: rt.Number,
frequency: rt.Union(
rt.Literal('Weekly'),
rt.Literal('Fortnightly'),
rt.Literal('Monthly')
).optional(),
day_of_month: rt.Number.withConstraint(
(n) => n >= 1 && n <= 28 || 'Day must be between 1 and 28'
).optional(),
total_dollar_amount: rt.Number.withConstraint(
(n) => n > 0 || 'Amount must be positive'
).optional(),
payment_method: rt.Union(
rt.Literal('Credit card'),
rt.Literal('Direct debit')
).optional(),
card_token: rt.String.optional(),
account_number: rt.String.optional(),
bsb_code: rt.String.optional(),
delay_change_until: rt.String.withConstraint(
(s) => dayjs(s).isValid() || 'Invalid date'
).optional(),
});
type SubscriptionChangeParams = rt.Static<typeof SubscriptionChangeParams>;
Step 3: Using the Automation Context
The S9AutomationContext provides access to all Stack9 services.
Database Access
execute = async ({ db, params, next }: S9AutomationContext) => {
const { customer_id } = Params.check(params);
// Direct Knex queries
const customer = await db.knex('customer')
.where({ id: customer_id })
.where({ _is_deleted: false })
.first();
// Transactions
await db.knex.transaction(async (trx) => {
await trx('customer').where({ id: customer_id }).update({ status: 'active' });
await trx('audit_log').insert({
entity_type: 'customer',
entity_id: customer_id,
action: 'status_changed',
});
});
// Sequence generation
const nextOrderNumber = await db.sequence.nextVal('sales_order', 1, 999999);
return next({ customer, order_number: nextOrderNumber });
};
Entity Service
execute = async ({ services, params, next }: S9AutomationContext) => {
const { customer_id } = Params.check(params);
// Find one entity
const customer = await services.entity.findOne('customer', Customer, {
$where: {
id: customer_id,
_is_deleted: false,
},
});
// Find all with relations
const salesOrders = await services.entity.findAll('sales_order', SalesOrder, {
$where: {
customer_id: customer_id,
_is_deleted: false,
},
$withRelated: ['sales_order_items(notDeleted)', 'customer(notDeleted)'],
});
// Create entity
const newTask = await services.entity.insert('task', {
customer_id: customer_id,
summary: 'Follow up',
is_completed: false,
});
// Update entity
await services.entity.update('customer', customer_id, {
last_contact_date: new Date(),
});
return next({ customer, sales_orders, new_task: newTask });
};
Workflow Service
execute = async ({ services, params, next }: S9AutomationContext) => {
const { entity_id, action } = Params.check(params);
// Move workflow to next step
await services.workflow.move('subscription', entity_id, action, {
outcome_reason: 'Validation successful',
});
// Execute workflow action
await services.workflow.executeAction('sales_order', entity_id, 'approve');
return next({ workflow_moved: true });
};
Message Queue Service
execute = async ({ services, params, next }: S9AutomationContext) => {
const { customer_id } = Params.check(params);
// Send message to queue for async processing
await services.message.realtime.sendMessage({
queue: 'send_welcome_email',
body: JSON.stringify({ customer_id }),
entityType: 'customer',
entityId: customer_id,
});
return next({ queued: true });
};
Connector Service
execute = async ({ services, params, next, logger }: S9AutomationContext) => {
const { email } = Params.check(params);
try {
// Get connector
const emailConnector = services.connector.get('email_service');
// Call external API
const response = await emailConnector.call({
method: 'POST',
path: '/send',
body: {
to: email,
subject: 'Welcome',
template: 'welcome_email',
},
});
return next({
response_code: 200,
email_sent: true,
message_id: response.id,
});
} catch (error) {
logger.error('Failed to send email', { error, email });
return next({
response_code: 500,
email_sent: false,
error: error.message,
});
}
};
Logger Service
execute = async ({ logger, params, next }: S9AutomationContext) => {
const { customer_id } = Params.check(params);
// Info logging
logger.info('Processing customer', { customer_id });
// Warning logging
logger.warn('Customer has overdue invoices', { customer_id, count: 3 });
// Error logging
try {
await processCustomer(customer_id);
} catch (error) {
logger.error('Customer processing failed', {
error: error.message,
stack: error.stack,
customer_id,
});
}
return next();
};
Step 4: Error Handling and Responses
Proper error handling is critical for reliable automations.
Basic Error Handling
execute = async ({ params, services, next, logger }: S9AutomationContext) => {
try {
const { customer_id } = Params.check(params);
const customer = await services.entity.findOne('customer', Customer, {
$where: { id: customer_id },
});
if (!customer) {
return next({
response_code: 404,
message: 'Customer not found',
});
}
// Process customer...
return next({
response_code: 200,
message: 'Success',
data: customer,
});
} catch (error) {
logger.error('Action failed', { error, params });
return next({
response_code: 500,
message: 'Internal server error',
error: error.message,
});
}
};
Validation Errors
import { SystemError } from '@april9au/stack9-sdk';
execute = async ({ params, services, next, logger }: S9AutomationContext) => {
try {
const { customer_id, amount } = Params.check(params);
// Business validation
const customer = await services.entity.findOne('customer', Customer, {
$where: { id: customer_id },
});
if (!customer) {
throw new SystemError('Customer not found', 404);
}
if (amount > customer.credit_limit) {
throw new SystemError('Amount exceeds credit limit', 400);
}
// Process...
return next({
response_code: 200,
message: 'Success',
});
} catch (error) {
if (error instanceof SystemError) {
return next({
response_code: error.status,
message: error.message,
});
}
logger.error('Unexpected error', { error, params });
return next({
response_code: 500,
message: 'Internal server error',
});
}
};
Graceful Degradation
execute = async ({ params, services, next, logger }: S9AutomationContext) => {
const { customer_id } = Params.check(params);
// Critical operation - must succeed
const customer = await services.entity.findOne('customer', Customer, {
$where: { id: customer_id },
});
// Non-critical operation - can fail without blocking
let emailSent = false;
try {
const emailConnector = services.connector.get('email_service');
await emailConnector.call({
method: 'POST',
path: '/send',
body: { to: customer.email },
});
emailSent = true;
} catch (error) {
logger.warn('Email sending failed, continuing anyway', {
error,
customer_id,
});
}
return next({
response_code: 200,
customer,
email_sent: emailSent,
});
};
Step 5: Real-World Example - Get Customer Dashboard Data
This complex action demonstrates many patterns:
File: src/action-types/getCustomerDashboardData.ts
import {
S9AutomationActionType,
S9AutomationActionTypeDescription,
S9AutomationContext,
S9InputTypes,
SystemError,
} from '@april9au/stack9-sdk';
import moment from 'moment';
import * as rt from 'runtypes';
import { SalesOrderWorkflowOutcome } from '../models/SalesOrderWorkflow';
import { AttentionFlag } from '../models/stack9/AttentionFlag';
import { DBCustomer } from '../models/stack9/Customer';
import { DBCustomerAttentionFlag } from '../models/stack9/CustomerAttentionFlag';
import { DBGame } from '../models/stack9/Game';
import { DBIssuedTicketRange } from '../models/stack9/IssuedTicketRange';
import { DBSalesOrder } from '../models/stack9/SalesOrder';
import { DBSalesOrderItem } from '../models/stack9/SalesOrderItem';
import { DBSubscription } from '../models/stack9/Subscription';
import { SubscriptionWorkflowSteps } from '../models/SubscriptionWorkflow';
import { decodeWebsiteToken } from '../utils/customerUtils';
const Payload = rt.Record({
token: rt.String,
});
type Payload = rt.Static<typeof Payload>;
// Define response types
const Customer = DBCustomer.pick(
'id',
'crn',
'name',
'last_name',
'email_address',
'phone',
'address_line_1',
'address_line_2',
'suburb',
'post_code',
'state',
'country',
'dob'
);
const SalesOrder = DBSalesOrder.pick(
'id',
'_created_at',
'sales_order_number',
'business_unit',
'receipt_number',
'transaction_dt',
'customer_id',
'order_type',
'_workflow_outcome'
).extend({
total_payable_amount: rt.String.Or(rt.Number).nullable().optional(),
sales_order_items: rt.Array(
DBSalesOrderItem.pick('id', 'item_type', 'total_ticket_qty', 'game_id')
.extend({
total_item_amount: rt.String.Or(rt.Number).nullable().optional(),
issued_ticket_ranges: rt
.Array(DBIssuedTicketRange.pick('from', 'to', 'status'))
.nullable()
.optional(),
game: DBGame.pick('number', 'draw_dt').nullable().optional(),
})
.nullable()
.optional()
),
});
const Subscription = DBSubscription.pick(
'id',
'subscription_number',
'item_type',
'frequency',
'total_dollar_amount',
'total_ticket_qty',
'start_dt',
'next_debit_run_dt',
'last_payment_status',
'_workflow_current_step',
'payment_method'
);
export class GetCustomerDashboardData implements S9AutomationActionType {
description: S9AutomationActionTypeDescription = {
name: 'Get customer dashboard data',
key: 'get_customer_dashboard_data',
description: 'Fetch complete customer dashboard data',
icon: 'DashboardOutlined',
properties: [
{
name: 'token',
label: 'Authentication Token',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }],
},
],
};
execute = async ({
next,
params,
services,
}: S9AutomationContext): Promise<void> => {
try {
// Validate parameters
const { token } = Payload.check(params);
// Decode authentication token
const { email } = await decodeWebsiteToken(
services.environmentVariable,
token
);
// Find customer by website username
const customer = await services.entity.findOne(
'customer',
Customer,
{
$where: {
is_active: true,
_is_deleted: false,
website_username: email,
},
}
);
if (!customer) {
throw new SystemError('Customer not found', 404);
}
// Check for gambling self-exclusion flag
const hasGamblingSelfExclusion = await services.entity.findOne(
'customer_attention_flag',
DBCustomerAttentionFlag.pick('id', '_created_at').extend({
attention_flag: AttentionFlag.pick('flag', 'minimum_retention_days'),
}),
{
$where: {
_is_deleted: false,
customer_id: customer.id,
'attention_flag.flag': 'GAMBLING_SELF_BAN',
},
$withRelated: ['attention_flag(notDeleted)'],
}
);
// Calculate exclusion end date
const gamblingSelfExclusionDt =
hasGamblingSelfExclusion &&
hasGamblingSelfExclusion.attention_flag.minimum_retention_days
? moment(hasGamblingSelfExclusion._created_at).add(
hasGamblingSelfExclusion.attention_flag.minimum_retention_days,
'day'
)
: null;
// Fetch sales orders (last 24 months)
const date24MonthsAgo = moment()
.subtract(24, 'months')
.format('YYYY-MM-DD');
const salesOrders = await services.entity.findAll(
'sales_order',
SalesOrder,
{
$where: {
_is_deleted: false,
customer_id: customer.id,
_workflow_outcome: SalesOrderWorkflowOutcome.Success,
_created_at: {
$gte: date24MonthsAgo,
},
},
$withRelated: [
'sales_order_items(notDeleted)',
'sales_order_items(notDeleted).game(notDeleted)',
'sales_order_items(notDeleted).issued_ticket_ranges(notDeleted)',
],
},
{ method: 'withGraphFetched' }
);
// Fetch active subscriptions
const subscriptions = await services.entity.findAll(
'subscription',
Subscription,
{
$where: {
_is_deleted: false,
customer_id: customer.id,
_workflow_current_step: {
$in: [
SubscriptionWorkflowSteps.Active,
SubscriptionWorkflowSteps.Deferred,
SubscriptionWorkflowSteps.Suspended,
],
},
},
}
);
// Return complete dashboard data
return next({
response_code: 200,
data: {
...customer,
has_gambling_self_exclusion: Boolean(hasGamblingSelfExclusion),
...(gamblingSelfExclusionDt && {
gambling_self_exclusion_retention_dt:
gamblingSelfExclusionDt.format('DD/MM/YYYY'),
}),
sales_orders: salesOrders,
subscriptions,
},
});
} catch (error: unknown) {
if (error instanceof SystemError) {
return next({
response_code: error.status,
message: error.message,
});
}
throw error;
}
};
}
Key patterns demonstrated:
- Token authentication
- Complex type definitions
- Multiple entity queries with relations
- Conditional data fetching
- Calculated fields
- Proper error handling
- Structured response
Step 6: Integration with Workflows
Actions can integrate with workflows to move entities through states.