916 lines
28 KiB
TypeScript
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));
|
|
}
|
|
}
|