/** * 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; 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 ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 ): Promise { 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 { 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 { 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 { 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 { return new Promise((resolve) => setTimeout(resolve, ms)); } }