Per-Tenant Approval Rules
Each ApprovalEngine is created with a tenantId that scopes
its templates and instances. Your admin UI saves a chain per tenant, and you turn
it into that tenant's template with defineTemplate(). Different
tenants, different rules — one shared storage adapter.
import { ApprovalEngine } from 'hierarchical-approval';
import type { ApprovalTemplateConfig } from 'hierarchical-approval';
import { PostgresAdapter } from 'hierarchical-approval/adapters/postgres';
// One engine per tenant: tenantId scopes templates and instances.
// Share the adapter (one pool) across all of them.
const adapter = new PostgresAdapter({ connectionString: process.env.DATABASE_URL });
const engines = new Map<string, ApprovalEngine>();
function engineFor(tenantId: string): ApprovalEngine {
let engine = engines.get(tenantId);
if (!engine) {
engine = new ApprovalEngine({
adapter,
tenantId,
// Resolves every { type: 'role' } approver. The engine calls this with
// the role only, so close over the tenant here.
orgProvider: { getUsersByRole: (role) => directory.usersWithRole(role, tenantId) },
});
engines.set(tenantId, engine);
}
return engine;
}
// The row your admin UI saves for each tenant
interface TenantApprovalConfig {
levels: Array<{ approverRoles: string[]; requireAll: boolean }>;
}
const FALLBACK_CONFIG: TenantApprovalConfig = {
levels: [{ approverRoles: ['admin'], requireAll: false }],
};
function toTemplate(config: TenantApprovalConfig): ApprovalTemplateConfig {
return {
name: 'tenant-document-approval',
documentType: 'document',
levels: config.levels.map((level, index) => ({
level: index + 1,
name: `Level ${index + 1}`,
approvers: level.approverRoles.map((role) => ({ type: 'role', role })),
mode: level.requireAll ? 'all' : 'any',
})),
};
}
// Run when the tenant saves their chain (and once for tenants that never did)
await engineFor(tenantId).defineTemplate(toTemplate(config ?? FALLBACK_CONFIG)); Multi-tenancy introduces unique approval edge cases.
These failures only appear at scale — after your first hundred tenants.
Tenant suspended mid-approval
A tenant's subscription lapses while they have three approval workflows in flight. Blocking all operations would freeze legitimate in-progress work and anger approvers who are trying to do the right thing.
// Block new submissions from suspended tenants while
// allowing existing in-flight approvals to complete
async function submitDocument(tenantId: string, documentId: string, actorId: string, data: Record<string, unknown>) {
const tenant = await db.query(
'SELECT status FROM tenants WHERE id = $1',
[tenantId]
);
if (tenant.rows[0]?.status === 'suspended') {
throw new Error(`Tenant ${tenantId} is suspended. Contact support to reactivate.`);
}
return engineFor(tenantId).submit({
templateName: 'tenant-document-approval',
documentType: 'document',
documentId,
submittedBy: actorId,
data,
});
}
// approve() and reject() go straight to the engine, so instances that were
// already in flight finish normally.
Check the tenant's status before submit(); leave approve/reject
alone so existing workflows complete. In-flight approvals resolve cleanly.
Invalid tenant chain configuration
A tenant admin accidentally saves a chain with no levels or
no approvers on a level. Saving it as the tenant's template would be rejected
at defineTemplate(), after the admin has already left the settings page.
// Validate tenant chain config before saving it
function validateChainConfig(tenantId: string, config: TenantApprovalConfig): string | null {
const result = engineFor(tenantId).validateTemplate(toTemplate(config));
if (result.valid) return null;
return result.errors.map((e) => `${e.field}: ${e.message}`).join('; ');
}
// In your admin API endpoint:
const error = validateChainConfig(tenantId, newConfig);
if (error) {
return res.status(400).json({ error });
}
await db.query(/* upsert config */);
await engineFor(tenantId).updateTemplate(toTemplate(newConfig));
Validate at save time with engine.validateTemplate(), which returns
every problem without persisting anything. Return the messages to the admin UI
before the broken config is saved.
In-flight instances after config change
A tenant updates their chain to add a CFO level. There are 12 approval instances already in progress. Should the new level apply to them?
engine.submit() stores a snapshot of the template on the instance — updateTemplate() doesn't change in-flight instances
Each instance keeps the template it was submitted with, so prior approvals stay
valid. Note that updateData() can still recompute the remaining levels
of an instance from its own template.
Your next approval workflow
ships faster than the last one.
Drop it into any Node.js project. No database required for local development.
$ npm install hierarchical-approval Peer dep for Postgres: npm install pg @types/pg