Development
Business Logic
How to write hooks and triggers for business logic in HotCRM
Business Logic
HotCRM uses hooks (also called triggers) to implement server-side business logic. Hooks execute automatically when records are created, updated, or deleted.
Hook Architecture
Hooks follow the File Suffix Protocol:
{object_name}.hook.tsExample: opportunity.hook.ts, lead.hook.ts
Hook Events
Hooks can fire at different points in the record lifecycle:
- beforeInsert: Before a record is created
- afterInsert: After a record is created
- beforeUpdate: Before a record is updated
- afterUpdate: After a record is updated
- beforeDelete: Before a record is deleted
- afterDelete: After a record is deleted
Basic Hook Structure
import type { HookSchema } from '@objectstack/spec/data';
import { db } from '@hotcrm/core';
export interface TriggerContext {
old?: Record<string, any>; // Previous record state (updates only)
new: Record<string, any>; // New record state
db: typeof db; // Database client
user: { // Current user
id: string;
name: string;
email: string;
};
}
const MyHook: HookSchema = {
name: 'MyHookName',
object: 'ObjectName',
events: ['afterUpdate'], // Hook events
handler: async (ctx: TriggerContext) => {
// Your business logic here
}
};
export default MyHook;Complete Example: Opportunity Hook
This is a real example from HotCRM that handles opportunity stage changes:
import type { HookSchema } from '@objectstack/spec/data';
import { db } from '@hotcrm/core';
export interface TriggerContext {
old?: Record<string, any>;
new: Record<string, any>;
db: typeof db;
user: { id: string; name: string; email: string; };
}
/**
* Opportunity Stage Change Trigger
*
* Handles automation when opportunity stage changes:
* - Closed Won: Creates contract, updates account status, logs activity
* - Closed Lost: Updates account, logs activity, sends notification
* - Stage changes: Validates data completeness, updates probability
*/
const OpportunityStageChange: HookSchema = {
name: 'OpportunityStageChange',
object: 'Opportunity',
events: ['afterUpdate'],
handler: async (ctx: TriggerContext) => {
try {
// Check if Stage actually changed
const stageChanged = ctx.old?.Stage !== ctx.new.Stage;
if (!stageChanged) {
return;
}
console.log(`🔄 Stage changed from "${ctx.old?.Stage}" to "${ctx.new.Stage}"`);
// Log activity for stage change
await logStageChange(ctx);
// Validate data completeness for advanced stages
await validateStageRequirements(ctx);
// Handle "Closed Won" scenario
if (ctx.new.Stage === 'Closed Won') {
await handleClosedWon(ctx);
}
// Handle "Closed Lost" scenario
if (ctx.new.Stage === 'Closed Lost') {
await handleClosedLost(ctx);
}
} catch (error) {
console.error('❌ Error in OpportunityTrigger:', error);
throw error;
}
}
};
/**
* Handle Closed Won automation
*/
async function handleClosedWon(ctx: TriggerContext): Promise<void> {
console.log('✅ Processing Closed Won automation...');
const opportunity = ctx.new;
if (!opportunity.AccountId) {
console.error('❌ Cannot process: Opportunity has no AccountId');
return;
}
// 1. Create Contract
const contract = await ctx.db.doc.create('Contract', {
AccountId: opportunity.AccountId,
OpportunityId: opportunity.Id,
Status: 'Draft',
ContractValue: opportunity.Amount || 0,
StartDate: new Date().toISOString().split('T')[0],
OwnerId: opportunity.OwnerId || ctx.user.id,
Description: `Auto-generated from Opportunity: ${opportunity.Name}`
});
console.log(`✅ Contract created: ${contract.Id}`);
// 2. Update Account Status
await ctx.db.doc.update('Account', opportunity.AccountId, {
CustomerStatus: 'Active Customer'
});
console.log('✅ Account status updated to Active Customer');
// 3. Log activity
await ctx.db.doc.create('Activity', {
Subject: `Opportunity Won: ${opportunity.Name}`,
Type: 'Milestone',
Status: 'Completed',
Priority: 'High',
AccountId: opportunity.AccountId,
WhatId: opportunity.Id,
OwnerId: ctx.user.id,
ActivityDate: new Date().toISOString().split('T')[0],
Description: `Opportunity "${opportunity.Name}" successfully won, amount: ${opportunity.Amount?.toLocaleString() || 0}`
});
console.log('✅ Activity logged for Closed Won');
}
async function handleClosedLost(ctx: TriggerContext): Promise<void> {
console.log('❌ Processing Closed Lost automation...');
const opportunity = ctx.new;
if (!opportunity.AccountId) {
return;
}
// Log activity for lost opportunity
await ctx.db.doc.create('Activity', {
Subject: `Opportunity Lost: ${opportunity.Name}`,
Type: 'Milestone',
Status: 'Completed',
Priority: 'Normal',
AccountId: opportunity.AccountId,
WhatId: opportunity.Id,
OwnerId: ctx.user.id,
ActivityDate: new Date().toISOString().split('T')[0],
Description: `Opportunity "${opportunity.Name}" lost, amount: ${opportunity.Amount?.toLocaleString() || 0}. Reason to be analyzed.`
});
console.log('✅ Activity logged for Closed Lost');
}
/**
* Log activity when stage changes
*/
async function logStageChange(ctx: TriggerContext): Promise<void> {
const opportunity = ctx.new;
const oldStage = ctx.old?.Stage || 'Unknown';
await ctx.db.doc.create('Activity', {
Subject: `Opportunity Stage Changed: ${oldStage} → ${ctx.new.Stage}`,
Type: 'Stage Change',
Status: 'Completed',
Priority: 'Normal',
AccountId: opportunity.AccountId,
WhatId: opportunity.Id,
OwnerId: ctx.user.id,
ActivityDate: new Date().toISOString().split('T')[0],
Description: `Opportunity stage changed from "${oldStage}" to "${ctx.new.Stage}"`
});
}
/**
* Validate required fields for advanced stages
*/
async function validateStageRequirements(ctx: TriggerContext): Promise<void> {
const opportunity = ctx.new;
const stage = opportunity.Stage;
const warnings: string[] = [];
// Validation for Proposal stage
if (stage === 'Proposal' && !opportunity.Amount) {
warnings.push('Proposal stage should have an Amount specified');
}
// Validation for Negotiation stage
if (stage === 'Negotiation') {
if (!opportunity.Amount) {
warnings.push('Negotiation stage requires Amount');
}
if (!opportunity.ContactId) {
warnings.push('Negotiation stage should have a primary Contact');
}
if (!opportunity.NextStep) {
warnings.push('Negotiation stage should have clear Next Steps');
}
}
// Validation for Closed stages
if ((stage === 'Closed Won' || stage === 'Closed Lost') && !opportunity.Amount) {
warnings.push('Closed opportunities should have an Amount for reporting');
}
if (warnings.length > 0) {
console.warn(`⚠️ Stage validation warnings for ${opportunity.Name}:`, warnings);
}
}
export default OpportunityStageChange;Common Hook Patterns
1. Before Insert - Set Default Values
const LeadBeforeInsert: HookSchema = {
name: 'LeadBeforeInsert',
object: 'Lead',
events: ['beforeInsert'],
handler: async (ctx: TriggerContext) => {
const lead = ctx.new;
// Set default rating based on annual revenue
if (!lead.Rating && lead.AnnualRevenue) {
if (lead.AnnualRevenue > 10000000) {
lead.Rating = 'Hot';
} else if (lead.AnnualRevenue > 1000000) {
lead.Rating = 'Warm';
} else {
lead.Rating = 'Cold';
}
}
// Auto-assign to public pool if no owner
if (!lead.OwnerId) {
lead.IsInPublicPool = true;
lead.PoolEntryDate = new Date().toISOString();
}
// Calculate data completeness
lead.DataCompleteness = calculateCompleteness(lead);
}
};
function calculateCompleteness(lead: any): number {
const fields = ['FirstName', 'LastName', 'Email', 'Phone', 'Company', 'Title', 'Industry'];
const filled = fields.filter(f => lead[f]).length;
return Math.round((filled / fields.length) * 100);
}2. After Insert - Create Related Records
const AccountAfterInsert: HookSchema = {
name: 'AccountAfterInsert',
object: 'Account',
events: ['afterInsert'],
handler: async (ctx: TriggerContext) => {
const account = ctx.new;
// Create default contact for new account
await ctx.db.doc.create('Contact', {
FirstName: 'Primary',
LastName: 'Contact',
AccountId: account.Id,
Title: 'To Be Updated',
Email: account.Email || '',
OwnerId: account.OwnerId
});
// Create onboarding task
await ctx.db.doc.create('Task', {
Subject: `Onboard new account: ${account.Name}`,
Status: 'Not Started',
Priority: 'High',
WhatId: account.Id,
OwnerId: account.OwnerId,
DueDate: addDays(new Date(), 7)
});
}
};
function addDays(date: Date, days: number): string {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result.toISOString().split('T')[0];
}3. Before Update - Validate Changes
const ContractBeforeUpdate: HookSchema = {
name: 'ContractBeforeUpdate',
object: 'Contract',
events: ['beforeUpdate'],
handler: async (ctx: TriggerContext) => {
const oldContract = ctx.old!;
const newContract = ctx.new;
// Prevent changing activated contract
if (oldContract.Status === 'Activated' && newContract.Status !== 'Activated') {
throw new Error('Cannot modify activated contract status');
}
// Require approval for value changes > 10%
if (oldContract.ContractValue && newContract.ContractValue) {
const change = Math.abs(
(newContract.ContractValue - oldContract.ContractValue) / oldContract.ContractValue
);
if (change > 0.1 && !newContract.ApprovalStatus) {
throw new Error('Contract amount change exceeding 10% requires approval');
}
}
}
};4. After Update - Cascade Updates
const AccountAfterUpdate: HookSchema = {
name: 'AccountAfterUpdate',
object: 'Account',
events: ['afterUpdate'],
handler: async (ctx: TriggerContext) => {
const oldAccount = ctx.old!;
const newAccount = ctx.new;
// If account owner changed, update all related opportunities
if (oldAccount.OwnerId !== newAccount.OwnerId) {
const opportunities = await ctx.db.find('Opportunity', {
filters: [
['AccountId', '=', newAccount.Id],
['IsClosed', '=', false]
]
});
for (const opp of opportunities) {
await ctx.db.doc.update('Opportunity', opp.Id, {
OwnerId: newAccount.OwnerId
});
}
console.log(`✅ Updated owner for ${opportunities.length} opportunities`);
}
}
};5. Before Delete - Prevent Deletion
const AccountBeforeDelete: HookSchema = {
name: 'AccountBeforeDelete',
object: 'Account',
events: ['beforeDelete'],
handler: async (ctx: TriggerContext) => {
const account = ctx.new;
// Check for active opportunities
const activeOpps = await ctx.db.find('Opportunity', {
filters: [
['AccountId', '=', account.Id],
['IsClosed', '=', false]
],
limit: 1
});
if (activeOpps.length > 0) {
throw new Error('Cannot delete account with active opportunities');
}
// Check for active contracts
const activeContracts = await ctx.db.find('Contract', {
filters: [
['AccountId', '=', account.Id],
['Status', '=', 'Activated']
],
limit: 1
});
if (activeContracts.length > 0) {
throw new Error('Cannot delete account with activated contracts');
}
}
};6. After Delete - Cleanup
const OpportunityAfterDelete: HookSchema = {
name: 'OpportunityAfterDelete',
object: 'Opportunity',
events: ['afterDelete'],
handler: async (ctx: TriggerContext) => {
const opportunity = ctx.new;
// Delete related quotes
const quotes = await ctx.db.find('Quote', {
filters: [['OpportunityId', '=', opportunity.Id]]
});
for (const quote of quotes) {
await ctx.db.doc.delete('Quote', quote.Id);
}
console.log(`✅ Deleted ${quotes.length} related quotes`);
}
};AI Integration in Hooks
Calculate AI Score
const LeadAfterInsert: HookSchema = {
name: 'LeadAfterInsert',
object: 'Lead',
events: ['afterInsert'],
handler: async (ctx: TriggerContext) => {
const lead = ctx.new;
// Calculate lead score using AI
const score = await calculateLeadScore(lead);
// Update lead with score
await ctx.db.doc.update('Lead', lead.Id, {
LeadScore: score.score,
AISummary: score.summary,
AIRecommendedAction: score.recommendedAction
});
}
};
async function calculateLeadScore(lead: any): Promise<{
score: number;
summary: string;
recommendedAction: string;
}> {
// Profile completeness (30%)
const completeness = calculateCompleteness(lead);
const completenessScore = (completeness / 100) * 30;
// Company quality (40%)
let companyScore = 0;
if (lead.AnnualRevenue > 10000000) companyScore += 20;
else if (lead.AnnualRevenue > 1000000) companyScore += 10;
if (lead.NumberOfEmployees > 500) companyScore += 20;
else if (lead.NumberOfEmployees > 50) companyScore += 10;
// Engagement (30%)
const engagementScore = 30; // Default for new leads
const totalScore = Math.round(completenessScore + companyScore + engagementScore);
let summary = '';
let action = '';
if (totalScore >= 70) {
summary = 'High quality lead: Large company, complete profile';
action = 'Contact immediately, schedule product demo';
} else if (totalScore >= 40) {
summary = 'Medium quality lead: Needs further requirement confirmation';
action = 'Send email to understand specific needs';
} else {
summary = 'Low quality lead: Incomplete profile or small company';
action = 'Add to nurturing workflow';
}
return { score: totalScore, summary, action };
}Generate AI Content
const OpportunityAfterUpdate: HookSchema = {
name: 'OpportunityAfterUpdate',
object: 'Opportunity',
events: ['afterUpdate'],
handler: async (ctx: TriggerContext) => {
const opportunity = ctx.new;
// Generate AI insights when stage advances
if (ctx.old?.Stage !== opportunity.Stage) {
const insights = await generateOpportunityInsights(opportunity);
await ctx.db.doc.update('Opportunity', opportunity.Id, {
AIWinProbability: insights.winProbability,
AIRiskFactors: insights.riskFactors,
AINextStepSuggestion: insights.nextStep,
AICompetitiveIntel: insights.competitiveIntel
});
}
}
};
async function generateOpportunityInsights(opp: any) {
// In real implementation, this would call an AI service
return {
winProbability: 75,
riskFactors: 'Competitor presence detected. Long sales cycle.',
nextStep: 'Schedule executive meeting to address pricing concerns',
competitiveIntel: 'Customer is also evaluating Salesforce. Highlight our AI capabilities.'
};
}Hook Best Practices
1. Keep Hooks Focused
// ✅ Good - One responsibility
const OpportunityClosedWon: HookSchema = {
name: 'OpportunityClosedWon',
object: 'Opportunity',
events: ['afterUpdate'],
handler: async (ctx) => {
if (ctx.new.Stage === 'Closed Won') {
await handleClosedWon(ctx);
}
}
};
// ❌ Bad - Too many responsibilities
const OpportunityEverything: HookSchema = {
handler: async (ctx) => {
// 200 lines of mixed logic
}
};2. Use Helper Functions
// ✅ Good - Modular helper functions
async function handleClosedWon(ctx: TriggerContext) {
await createContract(ctx);
await updateAccount(ctx);
await logActivity(ctx);
}
async function createContract(ctx: TriggerContext) {
// Focused logic
}3. Handle Errors Gracefully
handler: async (ctx: TriggerContext) => {
try {
await someOperation(ctx);
} catch (error) {
console.error('❌ Error:', error);
// Don't fail the entire transaction for non-critical operations
if (!isCritical(error)) {
console.warn('⚠️ Non-critical error, continuing...');
return;
}
// Re-throw critical errors
throw error;
}
}4. Avoid Recursive Triggers
// ✅ Good - Check for actual change
handler: async (ctx: TriggerContext) => {
const stageChanged = ctx.old?.Stage !== ctx.new.Stage;
if (!stageChanged) {
return; // Prevent infinite loop
}
// Process stage change
}5. Use Batch Operations
// ✅ Good - Batch update
const contactIds = opportunities.map(o => o.ContactId);
await ctx.db.batch.update('Contact', contactIds.map(id => ({
id,
data: { LastActivityDate: new Date() }
})));
// ❌ Bad - Individual updates
for (const opp of opportunities) {
await ctx.db.doc.update('Contact', opp.ContactId, {
LastActivityDate: new Date()
});
}6. Log Important Actions
handler: async (ctx: TriggerContext) => {
console.log(`🔄 Processing ${ctx.new.Name}`);
await doSomething(ctx);
console.log('✅ Operation completed');
if (warnings.length > 0) {
console.warn('⚠️ Warnings:', warnings);
}
}Testing Hooks
Unit Test Example
import { describe, it, expect, vi } from 'vitest';
import OpportunityStageChange from './opportunity.hook';
describe('OpportunityStageChange', () => {
it('should create contract when opportunity is closed won', async () => {
const mockDb = {
doc: {
create: vi.fn(),
update: vi.fn()
}
};
const ctx = {
old: { Stage: 'Negotiation' },
new: {
Stage: 'Closed Won',
Id: 'opp_123',
Name: 'Test Deal',
AccountId: 'acc_123',
Amount: 50000
},
db: mockDb as any,
user: { id: 'user_1', name: 'Test User', email: 'test@example.com' }
};
await OpportunityStageChange.handler(ctx);
expect(mockDb.doc.create).toHaveBeenCalledWith('Contract', expect.objectContaining({
AccountId: 'acc_123',
OpportunityId: 'opp_123'
}));
});
});Next Steps
- Creating Objects - Define custom objects
- UI Development - Build custom UI
- ObjectQL API - Database operations in hooks