916 lines
28 KiB
TypeScript

/**
* submission-engine.ts — 12-Step A2P Registration State Machine
* Orchestrates the entire Twilio TrustHub + A2P submission flow with retry logic
*/
import pino from 'pino';
import type {
RegistrationInput,
SubmissionRecord,
SubmissionStatus,
SidChain,
} from '../types';
import { TwilioApiClient, type TwilioConfig } from './twilio-client';
import { validateRegistrationInput, validateSidChainForStep } from './validators';
const logger = pino({ name: 'submission-engine' });
// ============================================================
// ENGINE CONFIGURATION
// ============================================================
export interface EngineConfig {
twilio: TwilioConfig;
maxRetries?: number; // Max retries per step (default: 3)
retryDelayMs?: number; // Base delay between retries (default: 2000ms)
pollIntervalMs?: number; // Interval for status polling (default: 30000ms)
pollTimeoutMs?: number; // Max time to wait for approval (default: 3600000ms = 1hr)
}
export interface StepResult {
success: boolean;
sidChain?: Partial<SidChain>;
status?: SubmissionStatus;
error?: string;
shouldRetry?: boolean;
}
// ============================================================
// SUBMISSION ENGINE
// ============================================================
export class SubmissionEngine {
private client: TwilioApiClient;
private config: EngineConfig;
constructor(config: EngineConfig) {
this.config = {
maxRetries: 3,
retryDelayMs: 2000,
pollIntervalMs: 30000,
pollTimeoutMs: 3600000,
...config,
};
this.client = new TwilioApiClient(config.twilio);
logger.info('SubmissionEngine initialized');
}
// ============================================================
// MAIN ORCHESTRATION
// ============================================================
/**
* Executes the complete 12-step A2P registration flow
* Updates the SubmissionRecord at each step
*/
async executeSubmission(
record: SubmissionRecord,
onUpdate?: (record: SubmissionRecord) => Promise<void>
): Promise<SubmissionRecord> {
try {
logger.info({ id: record.id }, 'Starting A2P submission');
// Validate input before starting
const validation = validateRegistrationInput(record.input);
if (!validation.success) {
logger.error({ errors: validation.errors }, 'Input validation failed');
record.status = 'manual_review';
record.failureReason = `Validation failed: ${JSON.stringify(validation.errors)}`;
if (onUpdate) await onUpdate(record);
return record;
}
// Execute steps sequentially
const steps = [
{ num: 1, fn: this.step1CreateSecondaryProfile.bind(this), status: 'creating_profile' as SubmissionStatus },
{ num: 2, fn: this.step2CreateBusinessEndUser.bind(this), status: 'creating_profile' as SubmissionStatus },
{ num: 3, fn: this.step3CreateAuthorizedRepEndUser.bind(this), status: 'creating_profile' as SubmissionStatus },
{ num: 4, fn: this.step4CreateAddressAndDocument.bind(this), status: 'creating_profile' as SubmissionStatus },
{ num: 5, fn: this.step5AssignSecondaryToPrimary.bind(this), status: 'creating_profile' as SubmissionStatus },
{ num: 6, fn: this.step6EvaluateProfile.bind(this), status: 'creating_profile' as SubmissionStatus },
{ num: 7, fn: this.step7SubmitProfile.bind(this), status: 'profile_submitted' as SubmissionStatus },
{ num: 8, fn: this.step8CreateTrustProduct.bind(this), status: 'creating_brand' as SubmissionStatus },
{ num: 9, fn: this.step9CreateBrandRegistration.bind(this), status: 'brand_pending' as SubmissionStatus },
{ num: 10, fn: this.step10CreateMessagingService.bind(this), status: 'creating_campaign' as SubmissionStatus },
{ num: 11, fn: this.step11CreateCampaign.bind(this), status: 'campaign_pending' as SubmissionStatus },
{ num: 12, fn: this.step12AssignPhoneNumbers.bind(this), status: 'campaign_pending' as SubmissionStatus },
];
for (const step of steps) {
logger.info({ step: step.num }, `Executing step ${step.num}`);
// Update status before executing step
record.status = step.status;
record.updatedAt = new Date();
if (onUpdate) await onUpdate(record);
// Execute step with retry logic
const result = await this.executeStepWithRetry(
step.num,
() => step.fn(record.input, record.sidChain)
);
if (!result.success) {
logger.error({ step: step.num, error: result.error }, `Step ${step.num} failed`);
record.status = result.shouldRetry ? 'remediation' : 'manual_review';
record.failureReason = result.error;
record.attemptCount++;
if (onUpdate) await onUpdate(record);
return record;
}
// Update SID chain
if (result.sidChain) {
record.sidChain = { ...record.sidChain, ...result.sidChain };
}
// Update status if provided
if (result.status) {
record.status = result.status;
}
record.updatedAt = new Date();
if (onUpdate) await onUpdate(record);
}
// Poll for final campaign approval
logger.info('Polling for campaign approval');
const approved = await this.pollCampaignApproval(
record.sidChain.brandRegistrationSid!,
record.sidChain.campaignSid!
);
if (approved) {
record.status = 'campaign_approved';
record.completedAt = new Date();
logger.info({ id: record.id }, 'Submission completed successfully');
} else {
record.status = 'campaign_failed';
record.failureReason = 'Campaign approval timeout';
logger.error({ id: record.id }, 'Campaign approval timeout');
}
record.updatedAt = new Date();
if (onUpdate) await onUpdate(record);
return record;
} catch (error: any) {
logger.error({ error: error.message }, 'Submission execution failed');
record.status = 'manual_review';
record.failureReason = error.message;
record.updatedAt = new Date();
if (onUpdate) await onUpdate(record);
return record;
}
}
// ============================================================
// STEP 1: CREATE SECONDARY CUSTOMER PROFILE
// ============================================================
private async step1CreateSecondaryProfile(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Check if already exists
if (sidChain.customerProfileSid) {
logger.info({ sid: sidChain.customerProfileSid }, 'CustomerProfile already exists');
return { success: true };
}
const result = await this.client.createSecondaryCustomerProfile(
input.business.businessName,
input.authorizedRep.email
);
return {
success: true,
sidChain: { customerProfileSid: result.sid },
};
} catch (error: any) {
return {
success: false,
error: `Step 1 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 2: CREATE BUSINESS END USER
// ============================================================
private async step2CreateBusinessEndUser(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 2);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Check if already exists
if (sidChain.businessEndUserSid) {
logger.info({ sid: sidChain.businessEndUserSid }, 'Business EndUser already exists');
return { success: true };
}
// Create EndUser
const endUser = await this.client.createBusinessEndUser(input.business);
// Assign to CustomerProfile
await this.client.assignEndUserToProfile(
sidChain.customerProfileSid!,
endUser.sid
);
return {
success: true,
sidChain: { businessEndUserSid: endUser.sid },
};
} catch (error: any) {
return {
success: false,
error: `Step 2 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 3: CREATE AUTHORIZED REP END USER
// ============================================================
private async step3CreateAuthorizedRepEndUser(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 3);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Check if already exists
if (sidChain.authorizedRepEndUserSid) {
logger.info({ sid: sidChain.authorizedRepEndUserSid }, 'Authorized Rep EndUser already exists');
return { success: true };
}
// Create EndUser
const endUser = await this.client.createAuthorizedRepEndUser(input.authorizedRep);
// Assign to CustomerProfile
await this.client.assignEndUserToProfile(
sidChain.customerProfileSid!,
endUser.sid
);
return {
success: true,
sidChain: { authorizedRepEndUserSid: endUser.sid },
};
} catch (error: any) {
return {
success: false,
error: `Step 3 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 4: CREATE ADDRESS + SUPPORTING DOCUMENT
// ============================================================
private async step4CreateAddressAndDocument(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 4);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Check if already exists
if (sidChain.addressSid && sidChain.supportingDocSid) {
logger.info('Address and SupportingDocument already exist');
return { success: true };
}
// Create Address
const address = await this.client.createAddress(input.address);
// Create SupportingDocument from Address
const doc = await this.client.createAddressSupportingDocument(
address.sid,
input.business.businessName
);
// Assign SupportingDocument to CustomerProfile
await this.client.assignSupportingDocToProfile(
sidChain.customerProfileSid!,
doc.sid
);
return {
success: true,
sidChain: {
addressSid: address.sid,
supportingDocSid: doc.sid,
},
};
} catch (error: any) {
return {
success: false,
error: `Step 4 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 5: ASSIGN SECONDARY TO PRIMARY
// ============================================================
private async step5AssignSecondaryToPrimary(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 5);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
await this.client.assignSecondaryToPrimary(sidChain.customerProfileSid!);
return { success: true };
} catch (error: any) {
// If already assigned, this might fail - check if it's a "duplicate" error
if (error.message.includes('already assigned') || error.message.includes('duplicate')) {
logger.info('Secondary already assigned to Primary');
return { success: true };
}
return {
success: false,
error: `Step 5 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 6: EVALUATE CUSTOMER PROFILE
// ============================================================
private async step6EvaluateProfile(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 6);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
const evaluation = await this.client.evaluateCustomerProfile(
sidChain.customerProfileSid!
);
// Check if evaluation passed
if (evaluation.status === 'compliant') {
return { success: true };
}
// Log non-compliant results
logger.warn({ results: evaluation.results }, 'CustomerProfile evaluation not compliant');
return {
success: false,
error: `CustomerProfile evaluation failed: ${JSON.stringify(evaluation.results)}`,
shouldRetry: false, // Evaluation failures usually need manual remediation
};
} catch (error: any) {
return {
success: false,
error: `Step 6 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 7: SUBMIT CUSTOMER PROFILE
// ============================================================
private async step7SubmitProfile(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 7);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Check if already approved
const isApproved = await this.client.isCustomerProfileApproved(
sidChain.customerProfileSid!
);
if (isApproved) {
logger.info('CustomerProfile already approved');
return { success: true, status: 'profile_submitted' };
}
await this.client.submitCustomerProfile(sidChain.customerProfileSid!);
return {
success: true,
status: 'profile_submitted',
};
} catch (error: any) {
return {
success: false,
error: `Step 7 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 8: CREATE TRUST PRODUCT
// ============================================================
private async step8CreateTrustProduct(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 8);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Check if already exists
if (sidChain.trustProductSid && sidChain.a2pProfileEndUserSid) {
logger.info('TrustProduct and A2P Profile already exist');
return { success: true };
}
// Create TrustProduct
const trustProduct = await this.client.createTrustProduct(
input.business.businessName,
input.authorizedRep.email
);
// Create A2P Profile EndUser
const a2pEndUser = await this.client.createA2PProfileEndUser(input.business);
// Assign EndUser to TrustProduct
await this.client.assignEndUserToTrustProduct(
trustProduct.sid,
a2pEndUser.sid
);
// Submit TrustProduct
await this.client.submitTrustProduct(trustProduct.sid);
return {
success: true,
sidChain: {
trustProductSid: trustProduct.sid,
a2pProfileEndUserSid: a2pEndUser.sid,
},
status: 'creating_brand',
};
} catch (error: any) {
return {
success: false,
error: `Step 8 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 9: CREATE BRAND REGISTRATION
// ============================================================
private async step9CreateBrandRegistration(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 9);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Check if already exists
if (sidChain.brandRegistrationSid) {
logger.info({ sid: sidChain.brandRegistrationSid }, 'Brand Registration already exists');
return { success: true };
}
// Wait for TrustProduct approval first
const trustProductApproved = await this.pollTrustProductApproval(
sidChain.trustProductSid!
);
if (!trustProductApproved) {
return {
success: false,
error: 'TrustProduct approval timeout',
shouldRetry: false,
};
}
// Create Brand Registration
const brand = await this.client.createBrandRegistration(
sidChain.trustProductSid!,
input.business.skipAutoSecVet
);
return {
success: true,
sidChain: { brandRegistrationSid: brand.sid },
status: 'brand_pending',
};
} catch (error: any) {
return {
success: false,
error: `Step 9 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 10: CREATE MESSAGING SERVICE
// ============================================================
private async step10CreateMessagingService(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 10);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Use existing messaging service if provided
if (input.phone.messagingServiceSid) {
logger.info({ sid: input.phone.messagingServiceSid }, 'Using existing Messaging Service');
return {
success: true,
sidChain: { messagingServiceSid: input.phone.messagingServiceSid },
};
}
// Check if already created
if (sidChain.messagingServiceSid) {
logger.info({ sid: sidChain.messagingServiceSid }, 'Messaging Service already exists');
return { success: true };
}
// Create new Messaging Service
const service = await this.client.createMessagingService(
`${input.business.businessName} - A2P`
);
// Assign phone numbers if provided
if (input.phone.phoneNumbers && input.phone.phoneNumbers.length > 0) {
await this.client.assignPhoneNumbersToService(
service.sid,
input.phone.phoneNumbers
);
}
return {
success: true,
sidChain: { messagingServiceSid: service.sid },
};
} catch (error: any) {
return {
success: false,
error: `Step 10 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 11: CREATE A2P CAMPAIGN
// ============================================================
private async step11CreateCampaign(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 11);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Check if already exists
if (sidChain.campaignSid) {
logger.info({ sid: sidChain.campaignSid }, 'Campaign already exists');
return { success: true };
}
// Wait for Brand approval first
const brandApproved = await this.pollBrandApproval(
sidChain.brandRegistrationSid!
);
if (!brandApproved) {
return {
success: false,
error: 'Brand approval timeout',
shouldRetry: false,
};
}
// Create Campaign
const campaign = await this.client.createUsAppToPersonCampaign(
sidChain.brandRegistrationSid!,
sidChain.messagingServiceSid!,
input.campaign,
input.campaign.messageFlow,
input.campaign.optInMessage,
input.campaign.optOutMessage,
input.campaign.helpMessage
);
return {
success: true,
sidChain: { campaignSid: campaign.sid },
status: 'campaign_pending',
};
} catch (error: any) {
return {
success: false,
error: `Step 11 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// STEP 12: ASSIGN PHONE NUMBERS TO CAMPAIGN
// ============================================================
private async step12AssignPhoneNumbers(
input: RegistrationInput,
sidChain: SidChain
): Promise<StepResult> {
try {
// Validate prerequisites
const validation = validateSidChainForStep(sidChain, 12);
if (!validation.valid) {
return {
success: false,
error: `Missing required SIDs: ${validation.missing?.join(', ')}`,
shouldRetry: false,
};
}
// Phone numbers are automatically associated via Messaging Service
// This step is mainly a confirmation
if (input.phone.phoneNumbers && input.phone.phoneNumbers.length > 0) {
await this.client.assignPhoneNumbersToCampaign(
sidChain.messagingServiceSid!,
input.phone.phoneNumbers
);
}
logger.info('Phone numbers assigned/confirmed');
return { success: true };
} catch (error: any) {
return {
success: false,
error: `Step 12 failed: ${error.message}`,
shouldRetry: true,
};
}
}
// ============================================================
// RETRY LOGIC
// ============================================================
/**
* Executes a step with exponential backoff retry logic
*/
private async executeStepWithRetry(
stepNum: number,
stepFn: () => Promise<StepResult>
): Promise<StepResult> {
const maxRetries = this.config.maxRetries || 3;
let attempt = 0;
let lastError: string = '';
while (attempt < maxRetries) {
try {
const result = await stepFn();
if (result.success) {
return result;
}
lastError = result.error || 'Unknown error';
// Don't retry if explicitly told not to
if (!result.shouldRetry) {
return result;
}
attempt++;
if (attempt < maxRetries) {
const delay = (this.config.retryDelayMs || 2000) * Math.pow(2, attempt - 1);
logger.warn(
{ step: stepNum, attempt, delay },
`Step ${stepNum} failed, retrying in ${delay}ms`
);
await this.sleep(delay);
}
} catch (error: any) {
lastError = error.message;
attempt++;
if (attempt < maxRetries) {
const delay = (this.config.retryDelayMs || 2000) * Math.pow(2, attempt - 1);
logger.warn(
{ step: stepNum, attempt, delay, error: error.message },
`Step ${stepNum} threw exception, retrying in ${delay}ms`
);
await this.sleep(delay);
}
}
}
return {
success: false,
error: `Step ${stepNum} failed after ${maxRetries} attempts: ${lastError}`,
shouldRetry: false,
};
}
// ============================================================
// POLLING HELPERS
// ============================================================
/**
* Polls for TrustProduct approval
*/
private async pollTrustProductApproval(trustProductSid: string): Promise<boolean> {
const startTime = Date.now();
const timeout = this.config.pollTimeoutMs || 3600000;
const interval = this.config.pollIntervalMs || 30000;
logger.info({ trustProductSid }, 'Polling for TrustProduct approval');
while (Date.now() - startTime < timeout) {
const approved = await this.client.isTrustProductApproved(trustProductSid);
if (approved) {
logger.info({ trustProductSid }, 'TrustProduct approved');
return true;
}
logger.info('TrustProduct not yet approved, waiting...');
await this.sleep(interval);
}
logger.error({ trustProductSid }, 'TrustProduct approval timeout');
return false;
}
/**
* Polls for Brand Registration approval
*/
private async pollBrandApproval(brandSid: string): Promise<boolean> {
const startTime = Date.now();
const timeout = this.config.pollTimeoutMs || 3600000;
const interval = this.config.pollIntervalMs || 30000;
logger.info({ brandSid }, 'Polling for Brand approval');
while (Date.now() - startTime < timeout) {
const status = await this.client.getBrandRegistrationStatus(brandSid);
if (status.identityStatus === 'VERIFIED' || status.identityStatus === 'SELF_DECLARED') {
logger.info({ brandSid, status: status.identityStatus }, 'Brand approved');
return true;
}
if (status.identityStatus === 'FAILED' || status.identityStatus === 'REJECTED') {
logger.error({ brandSid, status: status.identityStatus }, 'Brand registration failed');
return false;
}
logger.info({ status: status.identityStatus }, 'Brand not yet approved, waiting...');
await this.sleep(interval);
}
logger.error({ brandSid }, 'Brand approval timeout');
return false;
}
/**
* Polls for Campaign approval
*/
private async pollCampaignApproval(brandSid: string, campaignSid: string): Promise<boolean> {
const startTime = Date.now();
const timeout = this.config.pollTimeoutMs || 3600000;
const interval = this.config.pollIntervalMs || 30000;
logger.info({ brandSid, campaignSid }, 'Polling for Campaign approval');
while (Date.now() - startTime < timeout) {
const status = await this.client.getCampaignStatus(brandSid, campaignSid);
if (status.status === 'active' || status.status === 'APPROVED') {
logger.info({ campaignSid, status: status.status }, 'Campaign approved');
return true;
}
if (status.status === 'failed' || status.status === 'REJECTED') {
logger.error({ campaignSid, status: status.status }, 'Campaign failed');
return false;
}
logger.info({ status: status.status }, 'Campaign not yet approved, waiting...');
await this.sleep(interval);
}
logger.error({ campaignSid }, 'Campaign approval timeout');
return false;
}
/**
* Sleep helper
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}