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 '@april9/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 '@april9/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 '@april9/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.
Workflow Action Example
File: src/action-types/validateSubscriptionChangeRequest.ts
import {
S9AutomationActionType,
S9AutomationActionTypeDescription,
S9AutomationContext,
S9InputTypes,
IWorkflowService,
} from '@april9/stack9-sdk';
import * as rt from 'runtypes';
import { SubscriptionChangeRequestWorkflowActions } from '../models/SubscriptionChangeRequestWorkflow';
import { SubscriptionChangeRequest } from '../models/stack9/SubscriptionChangeRequest';
import { SubscriptionWithId } from '../models/stack9/Subscription';
import {
validateAccountNumber,
validateAccountToken,
validateBSB,
} from '../utils/externalTransactionUtils';
const Params = rt.Record({
entityId: rt.Number,
});
type Params = rt.Static<typeof Params>;
export class ValidateSubscriptionChangeRequest
implements S9AutomationActionType
{
description: S9AutomationActionTypeDescription = {
name: 'Validate Subscription Change Request',
key: 'validate_subscription_change_request',
description: 'Validate subscription change request data',
icon: 'CheckCircleOutlined',
properties: [
{
name: 'entityId',
label: 'Change Request ID',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }],
},
],
};
// Helper method to move workflow to failed state
async moveToValidationFailed(
wfService: IWorkflowService,
entityId: number,
reason: string
) {
await wfService.move(
'subscription_change_request',
entityId,
SubscriptionChangeRequestWorkflowActions.ValidationFailed,
{
outcome_reason: reason,
}
);
}
execute = async ({
params,
services,
next,
}: S9AutomationContext): Promise<void> => {
const { entityId } = Params.check(params);
// Fetch change request
const changeRequest = await services.entity.findOne(
'subscription_change_request',
SubscriptionChangeRequest.pick(
'subscription_id',
'frequency',
'total_dollar_amount',
'payment_method',
'account_number',
'bsb_code',
'card_token'
),
{
$where: {
_is_deleted: false,
_workflow_outcome: null,
id: entityId,
},
}
);
if (!changeRequest) {
await this.moveToValidationFailed(
services.workflow,
entityId,
'Change request not found'
);
return next();
}
// Fetch subscription
const subscription = await services.entity.findOne(
'subscription',
SubscriptionWithId.pick('id', 'item_type', 'game_type'),
{
$where: {
_is_deleted: false,
id: changeRequest.subscription_id,
},
}
);
if (!subscription) {
await this.moveToValidationFailed(
services.workflow,
entityId,
'Subscription not found'
);
return next();
}
// Validate payment method details
if (changeRequest.payment_method === 'Credit card') {
const [isValidAccountToken, errorAccountToken] = validateAccountToken(
changeRequest.card_token
);
if (!isValidAccountToken) {
await this.moveToValidationFailed(
services.workflow,
entityId,
errorAccountToken
);
return next();
}
}
if (changeRequest.payment_method === 'Direct debit') {
const [isValidAccountNumber, errorAccountNumber] = validateAccountNumber(
changeRequest.account_number
);
if (!isValidAccountNumber) {
await this.moveToValidationFailed(
services.workflow,
entityId,
errorAccountNumber
);
return next();
}
const [isValidBSB, errorBSB] = validateBSB(changeRequest.bsb_code);
if (!isValidBSB) {
await this.moveToValidationFailed(
services.workflow,
entityId,
errorBSB
);
return next();
}
}
// Validation passed - workflow will continue to next step
return next({
response_code: 200,
message: 'Validation successful',
});
};
}
Step 7: Testing Action Types
Testing ensures actions work correctly in isolation.
Unit Test Example
import { ValidateSubscriptionChangeRequest } from './validateSubscriptionChangeRequest';
import { SubscriptionChangeRequestWorkflowActions } from '../models/SubscriptionChangeRequestWorkflow';
describe('ValidateSubscriptionChangeRequest', () => {
it('should validate credit card token', async () => {
const mockContext = {
params: { entityId: 123 },
services: {
entity: {
findOne: jest
.fn()
.mockResolvedValueOnce({
subscription_id: 1,
payment_method: 'Credit card',
card_token: 'valid_token',
})
.mockResolvedValueOnce({
id: 1,
item_type: 'Ticket book',
}),
},
workflow: {
move: jest.fn(),
},
},
next: jest.fn(),
};
const action = new ValidateSubscriptionChangeRequest();
await action.execute(mockContext as any);
expect(mockContext.next).toHaveBeenCalledWith({
response_code: 200,
message: 'Validation successful',
});
});
it('should fail validation for invalid card token', async () => {
const mockContext = {
params: { entityId: 123 },
services: {
entity: {
findOne: jest.fn().mockResolvedValueOnce({
subscription_id: 1,
payment_method: 'Credit card',
card_token: 'invalid_token',
}),
},
workflow: {
move: jest.fn(),
},
},
next: jest.fn(),
};
const action = new ValidateSubscriptionChangeRequest();
await action.execute(mockContext as any);
expect(mockContext.services.workflow.move).toHaveBeenCalledWith(
'subscription_change_request',
123,
SubscriptionChangeRequestWorkflowActions.ValidationFailed,
expect.objectContaining({
outcome_reason: expect.stringContaining('Invalid'),
})
);
});
it('should handle missing change request', async () => {
const mockContext = {
params: { entityId: 999 },
services: {
entity: {
findOne: jest.fn().mockResolvedValueOnce(null),
},
workflow: {
move: jest.fn(),
},
},
next: jest.fn(),
};
const action = new ValidateSubscriptionChangeRequest();
await action.execute(mockContext as any);
expect(mockContext.services.workflow.move).toHaveBeenCalledWith(
'subscription_change_request',
999,
SubscriptionChangeRequestWorkflowActions.ValidationFailed,
{
outcome_reason: 'Change request not found',
}
);
});
});
Integration Test Example
describe('CreateCustomerTask Integration', () => {
it('should create task and attach to customer', async () => {
// Setup test database
const customer = await testDb.insert('customer', {
name: 'Test Customer',
email: 'test@example.com',
});
const context = {
params: {
customer_id: customer.id,
task_summary: 'Follow up',
task_description: 'Call customer about renewal',
due_date: '2025-01-15',
},
services: testServices,
db: testDb,
logger: testLogger,
next: jest.fn(),
};
const action = new CreateCustomerTask();
await action.execute(context as any);
// Verify task was created
const task = await testDb.query('task').where({ customer_id: customer.id }).first();
expect(task).toBeDefined();
expect(task.summary).toBe('Follow up');
expect(task.is_completed).toBe(false);
expect(context.next).toHaveBeenCalledWith({
response_code: 200,
message: 'Task created successfully',
});
});
});
Step 8: Real Examples from Sample Instance
Here are more real-world examples from the sample instance:
Example 1: Publish Business Supplier
Complex validation and rule matching:
export class PublishBusinessSupplier implements S9AutomationActionType {
description: S9AutomationActionTypeDescription = {
name: 'Publish business supplier',
key: 'publish_business_supplier',
description: 'Publish business supplier rules',
icon: 'CloudUploadOutlined',
properties: [
{
name: 'businessSupplierId',
label: 'Business Supplier ID',
type: S9InputTypes.ValueCodeMirror,
rules: [{ required: true }],
},
{
name: 'bypassValidation',
label: 'Bypass Validation',
type: S9InputTypes.Boolean,
},
],
};
private async checkForNonMatchingRules(
entityService: IEntityService,
supplierBusinessUnit: BusinessUnitType,
rules: PublishedRules['rules']
): Promise<NonMatchingRule[]> {
// Complex rule validation logic
// Generates all possible combinations of order types, payment methods, etc.
// Checks if each combination matches a rule
// Returns combinations that don't match any rule
}
execute = async ({
next,
params,
services,
}: S9AutomationContext): Promise<void> => {
const { businessSupplierId, bypassValidation } = Params.check(params);
const businessSupplier = await services.entity.findOne(
'business_supplier',
BusinessSupplierWithRules,
{
$where: { _is_deleted: false, id: businessSupplierId },
$withRelated: [
'business_supplier_rules(notDeleted)',
'business_supplier_rules.business_supplier_code(notDeleted)',
'business_supplier_rules.sales_channels(notDeleted)',
'business_supplier_rules.utm_sources(notDeleted)',
],
},
'withGraphFetched'
);
if (!businessSupplier) {
throw new SystemError(
`Business supplier #${businessSupplierId} not found!`
);
}
// Check rule coverage
const nonMatched = await this.checkForNonMatchingRules(
services.entity,
businessSupplier.business_unit,
rules
);
if (nonMatched.length === 0 || bypassValidation) {
// Publish rules
await services.entity.update('business_supplier', businessSupplier.id, {
match_result: null,
published_rules: { rules },
});
} else {
// Move to failed state with errors
await services.workflow.move(
'business_supplier',
businessSupplierId,
BusinessSupplierWorkflowActions.VerificationFailed,
{ outcome_reason: 'Missing rules!' }
);
await services.entity.update('business_supplier', businessSupplier.id, {
match_result: { errors: nonMatched },
published_rules: null,
});
}
return next();
};
}
Example 2: Handle Customer History Event
Event-driven architecture pattern:
export class HandleCustomerHistoryEvent implements S9AutomationActionType {
description: S9AutomationActionTypeDescription = {
name: 'Handle customer history event',
key: 'handle_customer_history_event',
description: 'Handle a range of customer history events',
icon: 'ClockCircleOutlined',
properties: [],
};
async execute({
next,
params,
services: { entity: entityService, queryLibrary },
db,
}: S9AutomationContext): Promise<void> {
try {
const { event_type, entity_id } = Params.check(params);
// Find handler for event type
const handler = handlers.find((h) => h.event_type === event_type);
if (handler === undefined) {
throw new Error(`Could not find handler for event type: ${event_type}`);
}
const {
having: checkCriteria,
insert: buildBody,
withFetched: fetchData,
Types: { Fetched: Data },
} = handler;
// Fetch related data
const [data] = rt.Array(Data).check(
JSON.parse(JSON.stringify(await fetchData(entity_id, entityService)))
);
if (!data) {
return next({ result: 'skipped because of empty data' });
}
// Check if criteria met
if (checkCriteria !== undefined && !checkCriteria(data)) {
return next({ result: 'skipped because of unmet criteria' });
}
// Build and insert event
const body = { ...buildBody(data), event_type };
const result = await entityService.insert('customer_history_event', body);
// Update customer last activity
const toUpdate = {
last_activity_dt: body.event_dt,
last_activity: body.event_type,
};
await db.knex('customer').where({ id: body.customer_id }).update(toUpdate);
// Sync to search index
await queryLibrary.runByKey('resynccustomers', {
vars: {
body: [{ update: { _id: body.customer_id } }, { doc: toUpdate }],
},
});
next({ result });
} catch (e: unknown) {
if (e instanceof rt.ValidationError) {
throw new Error(
`Failed validation in customer history event: ${
(params as any).event_type
} for ${(params as any).entity_id}`
);
}
throw e;
}
}
}
Step 9: Registering Action Types
Action types are registered in src/action-types/index.ts:
// Export all action types
export { CreateCustomerTask } from './createCustomerTask';
export { GetCustomerDashboardData } from './getCustomerDashboardData';
export { ValidateSubscriptionChangeRequest } from './validateSubscriptionChangeRequest';
export { PublishBusinessSupplier } from './publishBusinessSupplier';
export { HandleCustomerHistoryEvent } from './handleCustomerHistoryEvent';
export { SendWelcomeEmail } from './sendWelcomeEmail';
export { SyncCustomerData } from './syncCustomerData';
Stack9 automatically discovers and registers exported action types.
Best Practices
1. Single Responsibility
// Good - One clear purpose
export class SendWelcomeEmail implements S9AutomationActionType {
execute = async ({ params, services, next }) => {
await services.email.send('welcome', params);
next();
};
}
// Bad - Too many responsibilities
export class ProcessCustomerSignup implements S9AutomationActionType {
execute = async ({ params, services, next }) => {
await createCustomer();
await sendWelcomeEmail();
await syncToSearch();
await notifySlack();
await updateAnalytics();
next();
};
}
2. Validate All Parameters
// Good - Strict validation
const Params = rt.Record({
email: rt.String.withConstraint((s) => s.includes('@')),
age: rt.Number.withConstraint((n) => n >= 18),
role: rt.Union(rt.Literal('admin'), rt.Literal('user')),
});
// Bad - No validation
const params: any = context.params;
3. Handle Errors Gracefully
// Good - Proper error handling
execute = async ({ params, services, next, logger }) => {
try {
const result = await services.external.call(params);
next({ success: true, result });
} catch (error) {
logger.error('External call failed', { error, params });
return next({
response_code: 500,
message: 'Operation failed',
success: false,
});
}
};
4. Use Type Safety
// Good - Full type safety
import { DBCustomer } from '../models/stack9/Customer';
const customer = await services.entity.findOne<DBCustomer>(
'customer',
DBCustomer,
{ $where: { id: customerId } }
);
// Bad - No type safety
const customer = await services.entity.findOne(
'customer',
{} as any,
{ $where: { id: customerId } }
);
5. Log Important Events
// Good - Helpful logging
execute = async ({ params, logger, next }) => {
logger.info('Processing order', { orderId: params.orderId });
const result = await processOrder(params.orderId);
logger.info('Order processed successfully', {
orderId: params.orderId,
result,
});
next({ result });
};
6. Return Structured Responses
// Good - Consistent structure
return next({
response_code: 200,
message: 'Success',
data: {
customer_id: 123,
order_created: true,
},
});
// Bad - Inconsistent
return next({ ok: true, customer: 123 });
7. Document Complex Logic
/**
* Calculate shipping cost based on weight and destination
*
* Pricing rules:
* - Base rate: $5 + $0.50 per kg
* - International shipping adds 200% surcharge
* - Express shipping adds $10 flat fee
*/
export class CalculateShippingCost implements S9AutomationActionType {
execute = async ({ params, next }) => {
const { weight, destination, express } = Params.check(params);
const baseRate = 5 + weight * 0.5;
const internationalSurcharge = destination.international ? baseRate * 2 : 0;
const expressFee = express ? 10 : 0;
const total = baseRate + internationalSurcharge + expressFee;
return next({ shippingCost: total });
};
}
Troubleshooting
Action Not Appearing in UI
Problem: Action type not showing in automation builder
Solutions:
- Check action is exported in
src/action-types/index.ts - Verify
keyis unique - Restart dev server
- Check for TypeScript compilation errors
Parameters Not Validating
Problem: Invalid parameters not caught
Solutions:
- Verify runtype definition matches expected structure
- Call
Params.check(params)in execute method - Test with
Params.validate(testParams)in unit tests - Check for typos in field names
Database Queries Failing
Problem: Entity queries return empty or error
Solutions:
- Check entity name matches exactly (case-sensitive)
- Verify
$whereclause syntax - Add
_is_deleted: falseto queries - Use
$withRelatedfor loading relationships - Check database has data
Workflow Not Moving
Problem: services.workflow.move() doesn't work
Solutions:
- Verify entity supports workflows
- Check action name matches workflow definition
- Ensure entity is in correct workflow step
- Check workflow configuration in entity JSON
Next Steps
You've mastered building custom action types in Stack9! Continue learning:
- Automations - Using actions in workflows
- Validation Hooks - Entity-level validation
- Building Workflows - Complete workflow patterns
- Integrating External APIs - Connector patterns
Summary
In this guide, you learned how to:
- Structure action types with proper TypeScript types
- Define and validate parameters using runtypes
- Access Stack9 services (database, entity, workflow, connectors)
- Handle errors gracefully with structured responses
- Integrate actions with workflows
- Test action types in isolation
- Follow best practices for maintainable actions
- Learn from real production examples
Custom action types are the building blocks of Stack9 automations, enabling you to create powerful, reusable business logic that drives your applications.