Skip to content

Módulo Campanhas — Classes e Métodos

Documentação do módulo modules/marketing/ com foco em campanhas, envio de email e rastreamento.

Estrutura

modules/marketing/
├── campaigns/
│   ├── campaigns.module.ts
│   ├── campaigns.controller.ts
│   ├── campaigns.service.ts
│   └── services/
│       ├── campaign-scheduler.service.ts   # cron: campanhas recorrentes
│       ├── campaign-trigger.processor.ts   # fila: trigger agendado
│       ├── email-send.processor.ts         # fila: envio de email
│       └── behavior-filter.service.ts      # filtra audiência por comportamento
├── email-tracking/
│   ├── email-tracking.controller.ts        # pixel, cliques, unsubscribe
│   ├── email-tracking.service.ts
│   └── html-processor.ts                  # compilação MJML, tracking
└── segmentation/
    └── segmentation-query-builder.ts       # SQL dinâmico para segmentos

CampaignsService

Arquivo: campaigns/campaigns.service.ts | Decorator: @Injectable()

Dependências injetadas

typescript
constructor(
  private prisma: PrismaService,
  private encryption: EncryptionService,
  private metaGraph: MetaGraphService,
  private behaviorFilter: BehaviorFilterService,
  private queryBuilder: SegmentationQueryBuilder,
  @InjectQueue('whatsapp-send') private whatsappQueue: Queue,
  @InjectQueue('campaign-trigger') private campaignTriggerQueue: Queue,
  @InjectQueue('email-send') private emailQueue: Queue,
  private minio: MinioService,
) {}

Métodos públicos

typescript
// Cria campanha; agenda trigger se scheduledAt no futuro
async create(
  data: CreateCampaignDto,
  organizationId: string,
): Promise<Campaign>

// Atualiza campanha; re-agenda trigger se scheduledAt mudou
async update(
  id: string,
  data: Partial<CreateCampaignDto>,
  organizationId: string,
): Promise<Campaign>

// Remove campanha: cancela jobs BullMQ, limpa referências
async remove(id: string, organizationId: string): Promise<{ deleted: true }>

// Duplica campanha (sufixo " (Cópia)", status=draft)
async duplicate(id: string, organizationId: string): Promise<Campaign>

// Dispara envio WhatsApp
async sendWhatsappCampaign(
  campaignId: string,
  organizationId: string,
): Promise<{ queued: number; excludedOptOut: number; excludedNonMobile: number }>

// Dispara envio de email
async sendEmailCampaign(
  campaignId: string,
  organizationId: string,
): Promise<{ enqueued: number; excluded: number }>

// Preview de limite de rate (pre-flight antes de enviar)
async checkRateLimitForCampaign(
  campaignId: string,
  organizationId: string,
  targetDate?: Date,
): Promise<{
  tier: string
  limit: number
  isUnlimited: boolean
  sentOnDay: number
  committedOnDay: number
  remaining: number
  campaignSize: number
  willExceed: boolean
  exceedBy: number
}>

// Estimativa de audiência sem salvar
async estimateAudience(
  organizationId: string,
  segmentId?: string,
  behaviorRules?: BehaviorRulesDto,
  channel?: string,
): Promise<{ count: number }>

// Métricas em tempo real (últimas 7 dias)
async getRealtimeStats(organizationId: string): Promise<Array<{
  id: string
  name: string
  channel: string
  status: string
  sentAt: Date | null
  target: number
  total: number
  sent: number
  delivered: number
  read: number
  failed: number
}>>

// Analytics agregado WhatsApp por período
async getWhatsappAnalytics(organizationId, start?, end?): Promise<WhatsappAnalytics>

// Analytics agregado Email por período
async getEmailAnalytics(organizationId, start?, end?): Promise<EmailAnalytics>

// Envia mensagem de teste WhatsApp
async sendTestMessage(
  campaignId: string,
  organizationId: string,
  testPhone: string,
): Promise<{ jobId: string }>

// Envia email de teste (direto via Nodemailer, não fila)
async sendTestEmail(
  campaignId: string,
  organizationId: string,
  toEmail: string,
): Promise<{ sent: true }>

Lock atômico de envio

typescript
// Previne duplo disparo:
const result = await prisma.campaign.updateMany({
  where: { id: campaignId, status: { not: 'sending' } },
  data: { status: 'sending' },
})

if (result.count === 0) {
  throw new ConflictException('Campanha já está sendo enviada')
}

Job de trigger agendado

typescript
// Ao criar/editar campanha com scheduledAt:
await campaignTriggerQueue.add(
  'trigger-campaign',
  { campaignId, organizationId },
  {
    delay: scheduledAt.getTime() - Date.now(),
    jobId: `campaign-${campaignId}`,   // idempotente
  }
)

// Ao editar (remove o antigo e cria novo):
await campaignTriggerQueue.remove(`campaign-${campaignId}`)
await campaignTriggerQueue.add('trigger-campaign', data, { delay, jobId })

BehaviorFilterService

Arquivo: campaigns/services/behavior-filter.service.ts | Decorator: @Injectable()

Filtra clientes com base em comportamentos e regras de segment.

typescript
class BehaviorFilterService {
  constructor(
    private prisma: PrismaService,
    private queryBuilder: SegmentationQueryBuilder,
    @Inject(CUSTOMER_REPOSITORY) private customerRepo: ICustomerRepository,
  ) {}

  async countMatchingCustomers(
    organizationId: string,
    segmentId?: string,
    behaviorRules?: BehaviorRulesDto,
    channel?: string,
  ): Promise<number>

  async getMatchingCustomers(
    organizationId: string,
    segmentId?: string,
    behaviorRules?: BehaviorRulesDto,
    limitUnused?: number,
    countOnly?: boolean,
    channel?: string,
  ): Promise<Customer[] | number>
}

Tipos de evento de comportamento

EventoFiltro
purchasedFez compra no período
not_purchasedNão comprou nunca ou não no período
whatsapp_readAbriu mensagem WhatsApp
whatsapp_not_readRecebeu mas não abriu
whatsapp_repliedRespondeu no WhatsApp
received_campaignRecebeu campanha específica
not_received_campaignNão recebeu campanha
rfm_statusTem status RFM (value = nome do status)
in_segmentEstá em outro segmento (value = segmentId)
avg_ticket_aboveTicket médio > valor (value = valor em reais)
registeredCadastrado no período

Períodos disponíveis

typescript
type PeriodType =
  | 'last_7_days'
  | 'last_30_days'
  | 'last_60_days'
  | 'last_90_days'
  | 'custom'   // com customDays: number

Filtros por canal

typescript
// WhatsApp:
where: { phone: { not: null }, phoneType: 'MOBILE', whatsappOptOut: false }

// Email:
where: { email: { not: null }, emailOptOut: false }

// Padrão:
where: { phone: { not: null } }

CampaignScheduler

Arquivo: campaigns/services/campaign-scheduler.service.ts
Decorator: @Injectable() + OnApplicationBootstrap

Cron: campanhas recorrentes

typescript
@Cron('*/5 * * * *')  // a cada 5 minutos
async handleRecurringCampaigns(): Promise<void>

Critério de seleção: status IN ['draft', 'sent'], frequency != 'once', scheduledAt <= now, channel = 'WHATSAPP'.

Lock distribuído (Redis):

typescript
const locked = await redis.set(
  `campaign:lock:${campaignId}`,
  '1', 'EX', 300, 'NX'
)
if (!locked) return  // outra instância está processando

Cálculo do próximo run:

typescript
calculateNextRun(campaign: Campaign): Date | null {
  switch (campaign.frequency) {
    case 'daily':   return addDays(campaign.scheduledAt, 1)
    case 'weekly':  return addDays(campaign.scheduledAt, 7)
    case 'monthly': return addMonths(campaign.scheduledAt, 1)
                    // respeita scheduleDayOfMonth (ex: todo dia 30)
    default:        return null
  }
}

Recuperação de falhas (bootstrap)

typescript
async onApplicationBootstrap(): Promise<void> {
  // Varre JobRun com status='running' e started > 10min atrás:
  const stale = await prisma.jobRun.findMany({
    where: { status: 'running', startedAt: { lt: tenMinutesAgo } }
  })
  
  for (const job of stale) {
    // Re-dispara a campanha
    await this.campaignsService.sendWhatsappCampaign(job.campaignId, job.organizationId)
    await prisma.jobRun.update({ where: { id: job.id }, data: { status: 'completed' } })
  }
}

EmailTrackingService

Arquivo: email-tracking/email-tracking.service.ts

typescript
class EmailTrackingService {
  constructor(private prisma: PrismaService) {}

  // Registra abertura (idempotente: só na primeira vez)
  async recordOpen(trackingId: string): Promise<boolean>
  // → EmailMessage.openedAt = now
  // → CampaignMetric.opens++

  // Registra clique (idempotente: só na primeira vez)
  async recordClick(trackingId: string): Promise<boolean>
  // → EmailMessage.clickedAt = now
  // → CampaignMetric.clicks++

  // Processa unsubscribe
  async recordUnsubscribe(
    trackingId: string
  ): Promise<{ success: boolean; alreadyUnsubscribed: boolean }>
  // → EmailMessage.unsubscribedAt = now
  // → Customer.emailOptOut = true
  // → CampaignMetric.unsubscribes++
}

Endpoints públicos

GET  /email-tracking/t/:trackingId.png  → pixel 1x1 GIF (registra abertura)
GET  /email-tracking/c/:trackingId?url= → registra clique, redireciona (302)
GET  /email-tracking/u/:trackingId      → página de unsubscribe
POST /email-tracking/u/:trackingId      → unsubscribe one-click (RFC 8058)

html-processor.ts — Processamento de HTML de email

Funções puras (sem DI) para preparação do HTML de email.

typescript
// Compila MJML para HTML
compileMjml(input: string): string
// Detecta sintaxe MJML, sanitiza, compila, retorna HTML

// Injeta pixel de rastreamento antes de </body>
injectTrackingPixel(html, trackingId, baseUrl): string

// Envolve links com rastreamento de cliques
wrapLinks(html, trackingId, baseUrl): string
// Ignora: URLs internas de tracking, mailto:, tel:
// Substitui: href="https://..." → href="${baseUrl}/email-tracking/c/${trackingId}?url=..."

// Adiciona rodapé de descadastro
addUnsubscribeFooter(html, trackingId, baseUrl): string

// Retorna headers RFC 8058
getListUnsubscribeHeaders(trackingId, baseUrl): {
  'List-Unsubscribe': string    // "<${baseUrl}/email-tracking/u/${trackingId}>"
  'List-Unsubscribe-Post': string  // "List-Unsubscribe=One-Click"
}

// Upload de imagens base64 para MinIO
uploadBase64Images(html, uploader): Promise<string>
// Encontra src="data:image/..." → faz upload → substitui por URL pública

// Orquestração completa (chama todas as funções acima em ordem)
processEmailHtml(
  mjmlOrHtml: string,
  trackingId: string,
  baseUrl: string,
  imageUploader?: (buffer: Buffer, mimeType: string, key: string) => Promise<string>,
): Promise<string>

DTOs de Campanha

CreateCampaignDto

typescript
class CreateCampaignDto {
  name: string
  channel: 'WHATSAPP' | 'EMAIL'
  organizationId?: string
  storeId?: string
  scheduledAt?: string            // ISO date (futuro = agenda)
  content?: {
    subject?: string              // apenas para email
    body: string
    mediaUrl?: string
    designJson?: Record<string, any>  // GrapesJS JSON para editor
    emailTemplateId?: string
  }
  segmentId?: string
  tags?: string[]
  whatsappTemplateId?: string
  whatsappNumberId?: string
  templateVars?: Record<string, string>  // mapeamento variável → campo CRM
  behaviorRules?: BehaviorRulesDto
  frequency?: 'once' | 'daily' | 'weekly' | 'monthly'
  scheduleEndAt?: string
  scheduleDayOfWeek?: number      // 0-6 (para weekly)
  scheduleDayOfMonth?: number     // 1-31 (para monthly)
}

BehaviorRulesDto

typescript
class BehaviorRulesDto {
  include?: BehaviorRuleDto
  exclude?: BehaviorRuleDto
}

class BehaviorRuleDto {
  event: BehaviorEventType
  period?: {
    type: 'last_7_days' | 'last_30_days' | 'last_60_days' | 'last_90_days' | 'custom'
    customDays?: number
  }
  value?: string  // campaignId | segmentId | rfmStatus | valor monetário
}

Status de campanha

draft      → campanha criada mas não enviada
scheduled  → agendada para o futuro (scheduledAt presente, frequency=once)
sending    → disparo em andamento (lock atômico ativo)
sent       → todos os jobs processados (pelo menos 1 sucesso)
failed     → todos os jobs falharam

Status de mensagem

EmailMessage:
  QUEUED   → criada, aguardando job na fila
  SENT     → enviada pelo SMTP
  BOUNCED  → bounce HARD (550-559) ou SOFT (450-459)
  FAILED   → outro erro

WhatsappMessage:
  SENT     → aceita pela Meta API (WAM ID recebido)
  DELIVERED → entregue ao dispositivo
  READ     → lida pelo destinatário
  FAILED   → erro na Meta API

Fluxo: envio de campanha email

CampaignsService.sendEmailCampaign(campaignId)


1. Lock atômico: campaign.status → 'sending'
2. Verifica configuração SMTP
3. Busca conteúdo: CampaignContent ou EmailTemplate (MJML)
4. BehaviorFilterService.getMatchingCustomers()
   └── Filtra: email presente + !emailOptOut


5. buildAndEnqueueEmailJobs() por cliente:
   ├── compileMjml() → HTML
   ├── uploadBase64Images() → URLs MinIO
   ├── processEmailHtml():
   │   ├── wrapLinks() → tracking de cliques
   │   ├── addUnsubscribeFooter()
   │   └── injectTrackingPixel()
   ├── Cria EmailMessage(QUEUED)
   └── Enfileira job com delay=i*200ms


6. emailQueue.addBulk(jobs)
   [processados pelo EmailSendProcessor]


Por cada job:
   ├── Busca SMTP config + decripta senha
   ├── nodemailer.sendMail()
   ├── EmailMessage → SENT
   ├── CampaignMetric.sent++ delivered++
   └── checkCampaignCompletion()


7. Quando todos os QUEUED zerados:
   └── campaign.status → 'sent'

Documentação interna — Galdix CRM